From ecd62edd25dd6f548628657c4fca5bac9bd7c7e2 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 17 Aug 2026 02:09:08 -0700 Subject: [PATCH] fix(anki): allow timing review cards without media - Keep existing or create new cards without generating audio or images - Refine timing review timeline expansion labels and boundary markers --- changes/media-timing-review.md | 2 +- docs-site/anki-integration.md | 4 +- .../card-creation-manual-update.test.ts | 47 +++++++++++++++++++ .../card-creation-sentence-media.test.ts | 7 +++ src/anki-integration/card-creation.ts | 40 +++++++++------- .../note-update-workflow.test.ts | 42 +++++++++++++++++ src/anki-integration/note-update-workflow.ts | 7 ++- src/core/services/ipc.test.ts | 22 +++++++++ src/core/services/ipc.ts | 6 ++- src/renderer/index.html | 26 ++++++++-- src/renderer/modals/media-timing-review.ts | 12 +++-- src/renderer/style.css | 26 ++++++---- src/renderer/utils/dom.ts | 2 + src/types/anki.ts | 1 + 14 files changed, 204 insertions(+), 40 deletions(-) diff --git a/changes/media-timing-review.md b/changes/media-timing-review.md index f4947b0b..c03c7627 100644 --- a/changes/media-timing-review.md +++ b/changes/media-timing-review.md @@ -1,4 +1,4 @@ type: added 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 explicit cancellation choices. +- 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. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index b86925f9..2d10b5cf 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -179,9 +179,9 @@ Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMine The audio is uploaded to Anki's media folder and inserted as `[sound:audio_.mp3]`. -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; buttons reveal another five seconds before or after the visible timeline. 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, 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. ### Screenshots (Static) diff --git a/src/anki-integration/card-creation-manual-update.test.ts b/src/anki-integration/card-creation-manual-update.test.ts index 836b92eb..9051aaff 100644 --- a/src/anki-integration/card-creation-manual-update.test.ts +++ b/src/anki-integration/card-creation-manual-update.test.ts @@ -432,3 +432,50 @@ test('discarding an audio-card timing review deletes the note before evicting it assert.deepEqual(events, ['delete:42', 'cache:42']); assert.deepEqual(statusMessages, ['Card deleted.']); }); + +test('keeping an audio card without media skips generation and preserves the note', async () => { + let generatedAudio = false; + let deleted = false; + const { service, storedMedia } = createManualUpdateService({ + getMpvClient: () => + ({ + currentVideoPath: '/video.mp4', + currentSubText: '字幕', + currentSubStart: 4, + currentSubEnd: 6, + currentTimePos: 5, + }) as never, + client: { + addNote: async () => 0, + addTags: async () => undefined, + notesInfo: async () => [ + { + noteId: 42, + fields: { Expression: { value: '単語' }, Sentence: { value: '' } }, + }, + ], + updateNoteFields: async () => undefined, + storeMediaFile: async () => undefined, + findNotes: async () => [42], + retrieveMediaFile: async () => '', + deleteNotes: async () => { + deleted = true; + }, + }, + mediaGenerator: { + generateAudio: async () => { + generatedAudio = true; + return Buffer.from('audio'); + }, + generateScreenshot: async () => null, + generateAnimatedImage: async () => null, + }, + reviewMediaTiming: async () => ({ action: 'skip-media' }), + }); + + await service.markLastCardAsAudioCard(); + + assert.equal(generatedAudio, false); + assert.equal(deleted, false); + assert.deepEqual(storedMedia, []); +}); diff --git a/src/anki-integration/card-creation-sentence-media.test.ts b/src/anki-integration/card-creation-sentence-media.test.ts index 00f93804..87065fd0 100644 --- a/src/anki-integration/card-creation-sentence-media.test.ts +++ b/src/anki-integration/card-creation-sentence-media.test.ts @@ -153,4 +153,11 @@ test('sentence card writes generated audio only to sentence audio field', async deps.reviewMediaTiming = async () => ({ action: 'discard' }); assert.equal(await service.createSentenceCard('作らない', 20, 22), false); assert.equal(addedFields.length, 1); + + deps.reviewMediaTiming = async () => ({ action: 'skip-media' }); + assert.equal(await service.createSentenceCard('メディアなし', 30, 32), true); + assert.equal(addedFields.length, 2); + assert.equal(storedMedia.length, 1); + assert.deepEqual(audioRanges, [{ start: 11.4, end: 14.2, padding: 0 }]); + assert.deepEqual(requestedProperties, ['volume']); }); diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 51a28e76..d7147c62 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -478,6 +478,7 @@ export class CardCreationService { this.deps.showStatusNotification('Card deleted.'); return; } + const skipMedia = timingDecision.action === 'skip-media'; const exactReviewedRange = timingDecision.action === 'confirm'; if (timingDecision.action === 'confirm') { startTime = timingDecision.startTime; @@ -498,26 +499,28 @@ export class CardCreationService { const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); const audioFieldName = sentenceCardConfig.audioField; - try { - const audioFilename = this.generateAudioFilename(); - const audioBuffer = await this.mediaGenerateAudio( - mpvClient.currentVideoPath, - startTime, - endTime, - exactReviewedRange ? 0 : undefined, - ); + if (!skipMedia) { + try { + const audioFilename = this.generateAudioFilename(); + const audioBuffer = await this.mediaGenerateAudio( + mpvClient.currentVideoPath, + startTime, + endTime, + exactReviewedRange ? 0 : undefined, + ); - if (audioBuffer) { - await this.deps.client.storeMediaFile(audioFilename, audioBuffer); - updatedFields[audioFieldName] = `[sound:${audioFilename}]`; - miscInfoFilename = audioFilename; + if (audioBuffer) { + await this.deps.client.storeMediaFile(audioFilename, audioBuffer); + updatedFields[audioFieldName] = `[sound:${audioFilename}]`; + miscInfoFilename = audioFilename; + } + } catch (error) { + log.error('Failed to generate audio for audio card:', (error as Error).message); + errors.push('audio'); } - } catch (error) { - log.error('Failed to generate audio for audio card:', (error as Error).message); - errors.push('audio'); } - if (shouldGenerateImage(this.deps.getConfig())) { + if (!skipMedia && shouldGenerateImage(this.deps.getConfig())) { try { const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo); const imageFilename = this.generateImageFilename(); @@ -611,6 +614,7 @@ export class CardCreationService { this.deps.showStatusNotification('Card creation cancelled.'); return false; } + const skipMedia = timingDecision.action === 'skip-media'; const exactReviewedRange = timingDecision.action === 'confirm'; if (timingDecision.action === 'confirm') { startTime = timingDecision.startTime; @@ -618,8 +622,8 @@ export class CardCreationService { } const config = this.deps.getConfig(); - const generateAudio = shouldGenerateAudio(config); - const generateImage = shouldGenerateImage(config); + const generateAudio = !skipMedia && shouldGenerateAudio(config); + const generateImage = !skipMedia && shouldGenerateImage(config); const mediaResolverOptions = this.getMediaResolverOptions(); const videoPath = generateImage ? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions) diff --git a/src/anki-integration/note-update-workflow.test.ts b/src/anki-integration/note-update-workflow.test.ts index 2a08624f..ab709355 100644 --- a/src/anki-integration/note-update-workflow.test.ts +++ b/src/anki-integration/note-update-workflow.test.ts @@ -626,6 +626,48 @@ test('NoteUpdateWorkflow deletes an existing word card when timing review discar assert.deepEqual(harness.notifications, []); }); +test('NoteUpdateWorkflow keeps the word card but skips media after timing review', async () => { + const harness = createWorkflowHarness(); + const mediaCalls: string[] = []; + const deletedNoteIds: number[][] = []; + const queuedUpdates: unknown[] = []; + harness.deps.captureSubtitleMediaContext = () => ({ + source: 'overlay', + text: 'subtitle-text', + startTime: 4, + endTime: 6, + }); + harness.deps.getConfig = () => ({ + fields: { sentence: 'Sentence', image: 'Picture' }, + media: { generateAudio: true, generateImage: true }, + behavior: {}, + }); + harness.deps.reviewMediaTiming = async () => ({ action: 'skip-media' }); + harness.deps.generateAudio = async () => { + mediaCalls.push('audio'); + return Buffer.from('audio'); + }; + harness.deps.generateImage = async () => { + mediaCalls.push('image'); + return Buffer.from('image'); + }; + harness.deps.queuePendingYoutubeMediaUpdate = async (update) => { + queuedUpdates.push(update); + return true; + }; + harness.deps.client.deleteNotes = async (noteIds) => { + deletedNoteIds.push(noteIds); + }; + + await harness.workflow.execute(42); + + assert.deepEqual(mediaCalls, []); + assert.deepEqual(queuedUpdates, []); + assert.deepEqual(deletedNoteIds, []); + assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]); + assert.deepEqual(harness.notifications, [{ noteId: 42, label: 'taberu' }]); +}); + test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails', async () => { const harness = createWorkflowHarness(); const statusMessages: string[] = []; diff --git a/src/anki-integration/note-update-workflow.ts b/src/anki-integration/note-update-workflow.ts index 9aee0e0c..6dd11eab 100644 --- a/src/anki-integration/note-update-workflow.ts +++ b/src/anki-integration/note-update-workflow.ts @@ -218,6 +218,7 @@ export class NoteUpdateWorkflow { // timings per generator clips whichever line is on screen when each one starts. let mediaTimingContext = subtitleMiningContext ?? this.deps.captureSubtitleMediaContext?.() ?? null; + let skipMedia = false; const noteLabel = hasExpressionText ? expressionText : noteId; if (mediaTimingContext) { @@ -250,6 +251,8 @@ export class NoteUpdateWorkflow { endTime: timingDecision.endTime, mediaPaddingSeconds: 0, }; + } else if (timingDecision.action === 'skip-media') { + skipMedia = true; } } @@ -283,8 +286,8 @@ export class NoteUpdateWorkflow { } } - const generateAudio = config.media?.generateAudio !== false; - const generateImage = config.media?.generateImage !== false; + const generateAudio = !skipMedia && config.media?.generateAudio !== false; + const generateImage = !skipMedia && config.media?.generateImage !== false; const mediaCacheQueued = (generateAudio || generateImage) && this.deps.queuePendingYoutubeMediaUpdate ? await this.deps.queuePendingYoutubeMediaUpdate({ diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 21cb0d8d..69aa24a1 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -648,6 +648,28 @@ test('registerIpcHandlers exposes playback window activation request', async () assert.deepEqual(calls, ['activate']); }); +test('registerIpcHandlers accepts the keep-without-media timing decision', async () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const requests: unknown[] = []; + registerIpcHandlers( + createRegisterIpcDeps({ + resolveMediaTimingReview: async (request) => { + requests.push(request); + return { ok: true }; + }, + }), + registrar, + ); + + const handler = handlers.handle.get(IPC_CHANNELS.request.mediaTimingReviewResolve); + assert.ok(handler); + assert.deepEqual( + await handler!({}, { reviewId: 'review-1', decision: { action: 'skip-media' } }), + { ok: true }, + ); + assert.deepEqual(requests, [{ reviewId: 'review-1', decision: { action: 'skip-media' } }]); +}); + test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => { const { registrar, handlers } = createFakeIpcRegistrar(); const calls: string[] = []; diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 0f82051e..d55c026c 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -275,7 +275,11 @@ function parseMediaTimingReviewResolveRequest( const decision = record.decision; if (!decision || typeof decision !== 'object') return null; const decisionRecord = decision as Record; - if (decisionRecord.action === 'use-original' || decisionRecord.action === 'discard') { + if ( + decisionRecord.action === 'use-original' || + decisionRecord.action === 'skip-media' || + decisionRecord.action === 'discard' + ) { return { reviewId: record.reviewId, decision: { action: decisionRecord.action } }; } if ( diff --git a/src/renderer/index.html b/src/renderer/index.html index 12b01948..d19b9924 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -271,6 +271,12 @@ + +
- −5s + Earlier −2s - Drag an edge to trim, or drag the middle to slide the clip. + Drag an edge to trim, or drag the highlighted clip to move it.
@@ -411,6 +419,14 @@ > Use original timing +