mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-17 12:18:31 -07:00
feat(anki): add runtime toggle for media timing review
- Add session-only runtime option with live config hot-reload support - Document the runtime palette toggle
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
type: added
|
type: added
|
||||||
area: mining
|
area: mining
|
||||||
|
|
||||||
- Added optional pre-generation timing review for word, sentence, and audio cards with a compact speech-weighted waveform, clearly labeled mined-line boundaries, drag and keyboard adjustments, audio preview with a sweeping playhead, exact screenshot and AVIF timing, and cancellation choices that include keeping a card without media.
|
- Added optional pre-generation timing review for word, sentence, and audio cards with a compact speech-weighted waveform, clearly labeled mined-line boundaries, drag and keyboard adjustments, audio preview with a sweeping playhead, exact screenshot and AVIF timing, cancellation choices that include keeping a card without media, and a session-only runtime toggle.
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<time
|
|||||||
|
|
||||||
Set `media.reviewTiming` to `true` to pause playback and review each word, sentence, or audio card before its media is generated. The review opens with the subtitle range plus configured audio padding. Drag either edge of the clip to trim it, drag the middle to slide it without changing its length, or press anywhere else on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys, by 100 ms alone or 500 ms with Shift, and the 100 ms buttons do the same. Space previews the selection with a playhead that sweeps the clip, Enter confirms, and Escape cancels. The Earlier and Later buttons reveal another two seconds of available timeline without moving the selected clip. A speech-weighted waveform shows the mined subtitle as a tinted band with labeled line-start and line-end rails, making adjacent dialogue easier to distinguish. SubMiner uses a center channel when one carries dialogue, then falls back to a speech-band mono mix. Waveform analysis failure leaves the timing controls available. The confirmed range is exact: SubMiner does not apply audio padding a second time. Static screenshots use its midpoint, and animated AVIF clips use the full confirmed range.
|
Set `media.reviewTiming` to `true` to pause playback and review each word, sentence, or audio card before its media is generated. The review opens with the subtitle range plus configured audio padding. Drag either edge of the clip to trim it, drag the middle to slide it without changing its length, or press anywhere else on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys, by 100 ms alone or 500 ms with Shift, and the 100 ms buttons do the same. Space previews the selection with a playhead that sweeps the clip, Enter confirms, and Escape cancels. The Earlier and Later buttons reveal another two seconds of available timeline without moving the selected clip. A speech-weighted waveform shows the mined subtitle as a tinted band with labeled line-start and line-end rails, making adjacent dialogue easier to distinguish. SubMiner uses a center channel when one carries dialogue, then falls back to a speech-band mono mix. Waveform analysis failure leaves the timing controls available. The confirmed range is exact: SubMiner does not apply audio padding a second time. Static screenshots use its midpoint, and animated AVIF clips use the full confirmed range.
|
||||||
|
|
||||||
Canceling the review lets you keep editing, finish with the original timing, keep or create the card without audio or an image, or discard the card. Discard deletes an existing Yomitan or audio card and skips creation for a direct sentence card. Clipboard updates and stats-dashboard mining do not open timing review. Audio preview failure does not block confirmation or card creation. The option is disabled by default and hot-reloads.
|
Canceling the review lets you keep editing, finish with the original timing, keep or create the card without audio or an image, or discard the card. Discard deletes an existing Yomitan or audio card and skips creation for a direct sentence card. Clipboard updates and stats-dashboard mining do not open timing review. Audio preview failure does not block confirmation or card creation. The option is disabled by default and hot-reloads. You can also toggle **Review Media Timing** for the current session from the runtime options palette (`Ctrl/Cmd+Shift+O`).
|
||||||
|
|
||||||
### Screenshots (Static)
|
### Screenshots (Static)
|
||||||
|
|
||||||
|
|||||||
@@ -873,9 +873,10 @@ When config hot-reload updates shortcut/keybinding/style values, close and reope
|
|||||||
|
|
||||||
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
|
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
|
||||||
|
|
||||||
Current runtime options cover automatic card updates, known-word highlighting,
|
Current runtime options cover automatic card updates, media timing review,
|
||||||
known-word maturity coloring, N+1 annotation, JLPT underlines, frequency
|
known-word highlighting, known-word maturity coloring, N+1 annotation, JLPT
|
||||||
highlighting, known-word match mode, and Kiku field grouping mode.
|
underlines, frequency highlighting, known-word match mode, and Kiku field
|
||||||
|
grouping mode.
|
||||||
|
|
||||||
Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
|
Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
|
||||||
|
|
||||||
|
|||||||
@@ -2181,6 +2181,7 @@ test('runtime options registry is centralized', () => {
|
|||||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||||
assert.deepEqual(ids, [
|
assert.deepEqual(ids, [
|
||||||
'anki.autoUpdateNewCards',
|
'anki.autoUpdateNewCards',
|
||||||
|
'anki.mediaReviewTiming',
|
||||||
'subtitle.annotation.knownWords.highlightEnabled',
|
'subtitle.annotation.knownWords.highlightEnabled',
|
||||||
'subtitle.annotation.knownWords.maturityEnabled',
|
'subtitle.annotation.knownWords.maturityEnabled',
|
||||||
'subtitle.annotation.nPlusOne',
|
'subtitle.annotation.nPlusOne',
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.ankiConnect.media.reviewTiming,
|
defaultValue: defaultConfig.ankiConnect.media.reviewTiming,
|
||||||
description:
|
description:
|
||||||
'Review and preview subtitle media timing before SubMiner creates or enriches a mined card.',
|
'Review and preview subtitle media timing before SubMiner creates or enriches a mined card.',
|
||||||
|
runtime: runtimeOptionById.get('anki.mediaReviewTiming'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.media.generateImage',
|
path: 'ankiConnect.media.generateImage',
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ export function buildRuntimeOptionRegistry(
|
|||||||
behavior: { autoUpdateNewCards: value === true },
|
behavior: { autoUpdateNewCards: value === true },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'anki.mediaReviewTiming',
|
||||||
|
path: 'ankiConnect.media.reviewTiming',
|
||||||
|
label: 'Review Media Timing',
|
||||||
|
scope: 'ankiConnect',
|
||||||
|
valueType: 'boolean',
|
||||||
|
allowedValues: [true, false],
|
||||||
|
defaultValue: defaultConfig.ankiConnect.media.reviewTiming,
|
||||||
|
requiresRestart: false,
|
||||||
|
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
|
||||||
|
toAnkiPatch: (value) => ({
|
||||||
|
media: { reviewTiming: value === true },
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'subtitle.annotation.knownWords.highlightEnabled',
|
id: 'subtitle.annotation.knownWords.highlightEnabled',
|
||||||
path: 'ankiConnect.knownWords.highlightEnabled',
|
path: 'ankiConnect.knownWords.highlightEnabled',
|
||||||
|
|||||||
@@ -352,6 +352,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
|
|||||||
'ankiConnect.deck',
|
'ankiConnect.deck',
|
||||||
'ankiConnect.media.normalizeAudio',
|
'ankiConnect.media.normalizeAudio',
|
||||||
'ankiConnect.media.mirrorMpvVolume',
|
'ankiConnect.media.mirrorMpvVolume',
|
||||||
|
'ankiConnect.media.reviewTiming',
|
||||||
'ankiConnect.knownWords.highlightEnabled',
|
'ankiConnect.knownWords.highlightEnabled',
|
||||||
'ankiConnect.knownWords.refreshMinutes',
|
'ankiConnect.knownWords.refreshMinutes',
|
||||||
'ankiConnect.knownWords.addMinedWordsImmediately',
|
'ankiConnect.knownWords.addMinedWordsImmediately',
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
|
|||||||
next.ankiConnect.deck = 'Mining';
|
next.ankiConnect.deck = 'Mining';
|
||||||
next.ankiConnect.media.normalizeAudio = !prev.ankiConnect.media.normalizeAudio;
|
next.ankiConnect.media.normalizeAudio = !prev.ankiConnect.media.normalizeAudio;
|
||||||
next.ankiConnect.media.mirrorMpvVolume = !prev.ankiConnect.media.mirrorMpvVolume;
|
next.ankiConnect.media.mirrorMpvVolume = !prev.ankiConnect.media.mirrorMpvVolume;
|
||||||
|
next.ankiConnect.media.reviewTiming = !prev.ankiConnect.media.reviewTiming;
|
||||||
next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards;
|
next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards;
|
||||||
next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
|
next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
|
||||||
next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5;
|
next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5;
|
||||||
@@ -69,6 +70,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
|
|||||||
'ankiConnect.deck',
|
'ankiConnect.deck',
|
||||||
'ankiConnect.media.normalizeAudio',
|
'ankiConnect.media.normalizeAudio',
|
||||||
'ankiConnect.media.mirrorMpvVolume',
|
'ankiConnect.media.mirrorMpvVolume',
|
||||||
|
'ankiConnect.media.reviewTiming',
|
||||||
'ankiConnect.behavior.autoUpdateNewCards',
|
'ankiConnect.behavior.autoUpdateNewCards',
|
||||||
'ankiConnect.knownWords.highlightEnabled',
|
'ankiConnect.knownWords.highlightEnabled',
|
||||||
'ankiConnect.knownWords.refreshMinutes',
|
'ankiConnect.knownWords.refreshMinutes',
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
|||||||
'ankiConnect.deck',
|
'ankiConnect.deck',
|
||||||
'ankiConnect.media.normalizeAudio',
|
'ankiConnect.media.normalizeAudio',
|
||||||
'ankiConnect.media.mirrorMpvVolume',
|
'ankiConnect.media.mirrorMpvVolume',
|
||||||
|
'ankiConnect.media.reviewTiming',
|
||||||
'ankiConnect.behavior.autoUpdateNewCards',
|
'ankiConnect.behavior.autoUpdateNewCards',
|
||||||
'ankiConnect.knownWords.highlightEnabled',
|
'ankiConnect.knownWords.highlightEnabled',
|
||||||
'ankiConnect.knownWords.refreshMinutes',
|
'ankiConnect.knownWords.refreshMinutes',
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ test('createConfigHotReloadAppliedHandler applies only changed Anki media option
|
|||||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||||
config.ankiConnect.media.normalizeAudio = false;
|
config.ankiConnect.media.normalizeAudio = false;
|
||||||
config.ankiConnect.media.mirrorMpvVolume = false;
|
config.ankiConnect.media.mirrorMpvVolume = false;
|
||||||
|
config.ankiConnect.media.reviewTiming = true;
|
||||||
const ankiPatches: unknown[] = [];
|
const ankiPatches: unknown[] = [];
|
||||||
|
|
||||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||||
@@ -181,10 +182,18 @@ test('createConfigHotReloadAppliedHandler applies only changed Anki media option
|
|||||||
},
|
},
|
||||||
config,
|
config,
|
||||||
);
|
);
|
||||||
|
applyHotReload(
|
||||||
|
{
|
||||||
|
hotReloadFields: ['ankiConnect.media.reviewTiming'],
|
||||||
|
restartRequiredFields: [],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
assert.deepEqual(ankiPatches, [
|
assert.deepEqual(ankiPatches, [
|
||||||
{ media: { normalizeAudio: false } },
|
{ media: { normalizeAudio: false } },
|
||||||
{ media: { mirrorMpvVolume: false } },
|
{ media: { mirrorMpvVolume: false } },
|
||||||
|
{ media: { reviewTiming: true } },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ function buildAnkiRuntimeConfigPatch(
|
|||||||
if (diff.hotReloadFields.includes('ankiConnect.media.mirrorMpvVolume')) {
|
if (diff.hotReloadFields.includes('ankiConnect.media.mirrorMpvVolume')) {
|
||||||
mediaPatch.mirrorMpvVolume = config.ankiConnect.media.mirrorMpvVolume;
|
mediaPatch.mirrorMpvVolume = config.ankiConnect.media.mirrorMpvVolume;
|
||||||
}
|
}
|
||||||
|
if (diff.hotReloadFields.includes('ankiConnect.media.reviewTiming')) {
|
||||||
|
mediaPatch.reviewTiming = config.ankiConnect.media.reviewTiming;
|
||||||
|
}
|
||||||
if (Object.keys(mediaPatch).length > 0) {
|
if (Object.keys(mediaPatch).length > 0) {
|
||||||
patch.media = mediaPatch;
|
patch.media = mediaPatch;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,3 +82,19 @@ test('RuntimeOptionsManager keeps known-word and n+1 annotation toggles separate
|
|||||||
assert.equal(effective.nPlusOne?.enabled, true);
|
assert.equal(effective.nPlusOne?.enabled, true);
|
||||||
assert.deepEqual(patches, []);
|
assert.deepEqual(patches, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('RuntimeOptionsManager applies media timing review to the live Anki config', () => {
|
||||||
|
const baseConfig = structuredClone(DEFAULT_CONFIG.ankiConnect);
|
||||||
|
const patches: unknown[] = [];
|
||||||
|
const manager = new RuntimeOptionsManager(() => structuredClone(baseConfig), {
|
||||||
|
applyAnkiPatch: (patch) => {
|
||||||
|
patches.push(patch);
|
||||||
|
},
|
||||||
|
onOptionsChanged: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(manager.getOptionValue('anki.mediaReviewTiming'), false);
|
||||||
|
assert.equal(manager.setOptionValue('anki.mediaReviewTiming', true).ok, true);
|
||||||
|
assert.equal(manager.getEffectiveAnkiConnectConfig().media?.reviewTiming, true);
|
||||||
|
assert.deepEqual(patches, [{ media: { reviewTiming: true } }]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
|||||||
|
|
||||||
const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [
|
const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [
|
||||||
'anki.autoUpdateNewCards',
|
'anki.autoUpdateNewCards',
|
||||||
|
'anki.mediaReviewTiming',
|
||||||
'subtitle.annotation.knownWords.highlightEnabled',
|
'subtitle.annotation.knownWords.highlightEnabled',
|
||||||
'subtitle.annotation.knownWords.maturityEnabled',
|
'subtitle.annotation.knownWords.maturityEnabled',
|
||||||
'subtitle.annotation.nPlusOne',
|
'subtitle.annotation.nPlusOne',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export type RuntimeOptionId =
|
export type RuntimeOptionId =
|
||||||
| 'anki.autoUpdateNewCards'
|
| 'anki.autoUpdateNewCards'
|
||||||
|
| 'anki.mediaReviewTiming'
|
||||||
| 'subtitle.annotation.knownWords.highlightEnabled'
|
| 'subtitle.annotation.knownWords.highlightEnabled'
|
||||||
| 'subtitle.annotation.knownWords.maturityEnabled'
|
| 'subtitle.annotation.knownWords.maturityEnabled'
|
||||||
| 'subtitle.annotation.nPlusOne'
|
| 'subtitle.annotation.nPlusOne'
|
||||||
|
|||||||
Reference in New Issue
Block a user