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
This commit is contained in:
2026-08-30 01:56:16 -07:00
parent 046ab59da9
commit 475f81d96d
14 changed files with 204 additions and 40 deletions
@@ -563,3 +563,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, []);
});
@@ -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']);
});
+22 -18
View File
@@ -483,6 +483,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;
@@ -503,26 +504,28 @@ export class CardCreationService {
}
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();
@@ -616,6 +619,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;
@@ -623,8 +627,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)
@@ -668,6 +668,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[] = [];
+5 -2
View File
@@ -220,6 +220,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) {
@@ -252,6 +253,8 @@ export class NoteUpdateWorkflow {
endTime: timingDecision.endTime,
mediaPaddingSeconds: 0,
};
} else if (timingDecision.action === 'skip-media') {
skipMedia = true;
}
}
@@ -285,8 +288,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({
+22
View File
@@ -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[] = [];
+5 -1
View File
@@ -276,7 +276,11 @@ function parseMediaTimingReviewResolveRequest(
const decision = record.decision;
if (!decision || typeof decision !== 'object') return null;
const decisionRecord = decision as Record<string, unknown>;
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 (
+21 -5
View File
@@ -271,6 +271,12 @@
<span class="media-timing-review-original-boundary is-start"></span>
<span class="media-timing-review-original-boundary is-end"></span>
</div>
<span class="media-timing-review-original-label is-start" aria-hidden="true">
Line start
</span>
<span class="media-timing-review-original-label is-end" aria-hidden="true">
Line end
</span>
<div
id="mediaTimingReviewSelectedRange"
class="media-timing-review-selected-range"
@@ -299,18 +305,20 @@
id="mediaTimingReviewShowEarlier"
class="media-timing-review-expand-button"
type="button"
aria-label="Reveal five more seconds before the timeline"
aria-label="Show two more seconds before the visible timeline without moving the selected clip"
title="Show 2 more seconds before the visible timeline. The selected clip does not move."
>
&minus;5s
Earlier &minus;2s
</button>
<span>Drag an edge to trim, or drag the middle to slide the clip.</span>
<span>Drag an edge to trim, or drag the highlighted clip to move it.</span>
<button
id="mediaTimingReviewShowLater"
class="media-timing-review-expand-button"
type="button"
aria-label="Reveal five more seconds after the timeline"
aria-label="Show two more seconds after the visible timeline without moving the selected clip"
title="Show 2 more seconds after the visible timeline. The selected clip does not move."
>
+5s
Later +2s
</button>
</div>
</div>
@@ -411,6 +419,14 @@
>
Use original timing
</button>
<button
id="mediaTimingReviewSkipMedia"
class="media-timing-review-skip-button"
type="button"
title="Keep this card but do not add audio or an image."
>
Keep without media
</button>
<button
id="mediaTimingReviewDiscard"
class="media-timing-review-discard-button"
+9 -3
View File
@@ -5,7 +5,7 @@ import { createModalFocusGuard } from './modal-focus-guard';
const MINIMUM_CLIP_SECONDS = 0.1;
const FINE_ADJUST_SECONDS = 0.1;
const COARSE_ADJUST_SECONDS = 0.5;
const TIMELINE_EXPANSION_SECONDS = 5;
const TIMELINE_EXPANSION_SECONDS = 2;
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
@@ -447,8 +447,10 @@ export function createMediaTimingReviewModal(
ctx.dom.mediaTimingReviewCancelStep.classList.remove('hidden');
ctx.dom.mediaTimingReviewCancelMessage.textContent =
payload.noteId !== undefined
? 'Keep editing, finish this card with its original timing, or delete the card.'
: 'Keep editing, finish this card with its original timing, or do not create it.';
? 'Keep editing, keep this card without media, use its original timing, or delete it.'
: 'Keep editing, create this card without media, use its original timing, or do not create it.';
ctx.dom.mediaTimingReviewSkipMedia.textContent =
payload.noteId !== undefined ? 'Keep without media' : 'Create without media';
ctx.dom.mediaTimingReviewDiscard.textContent =
payload.noteId !== undefined ? 'Delete card' : "Don't create card";
ctx.dom.mediaTimingReviewCancelBack.focus();
@@ -668,6 +670,10 @@ export function createMediaTimingReviewModal(
'click',
() => void resolveReview({ action: 'use-original' }),
);
ctx.dom.mediaTimingReviewSkipMedia.addEventListener(
'click',
() => void resolveReview({ action: 'skip-media' }),
);
ctx.dom.mediaTimingReviewDiscard.addEventListener(
'click',
() => void resolveReview({ action: 'discard' }),
+18 -8
View File
@@ -1594,30 +1594,32 @@ body:focus-visible,
right: 0;
}
.media-timing-review-original-boundary::after {
.media-timing-review-original-label {
position: absolute;
z-index: 7;
padding: 3px 5px;
border: 1px solid color-mix(in srgb, var(--ctp-crust) 35%, transparent);
border-radius: 4px;
background: var(--ctp-peach);
box-shadow: 0 2px 6px color-mix(in srgb, var(--ctp-crust) 55%, transparent);
color: var(--ctp-crust);
font-size: 8px;
font-weight: 800;
letter-spacing: 0.06em;
line-height: 1;
pointer-events: none;
white-space: nowrap;
text-transform: uppercase;
}
.media-timing-review-original-boundary.is-start::after {
.media-timing-review-original-label.is-start {
top: 5px;
left: 5px;
content: 'line start';
left: calc(var(--original-start) + 7px);
}
.media-timing-review-original-boundary.is-end::after {
right: 5px;
.media-timing-review-original-label.is-end {
right: calc(100% - var(--original-end) + 7px);
bottom: 5px;
content: 'line end';
}
.media-timing-review-track.is-loading::after {
@@ -1749,6 +1751,7 @@ body:focus-visible,
.media-timing-review-expand-button,
.media-timing-review-quiet-button,
.media-timing-review-original-button,
.media-timing-review-skip-button,
.media-timing-review-discard-button,
.media-timing-review-play-button,
.media-timing-review-confirm-button,
@@ -1768,7 +1771,8 @@ body:focus-visible,
}
.media-timing-review-expand-button {
padding: 5px 10px;
min-width: 82px;
padding: 5px 9px;
color: var(--ctp-sky);
font-variant-numeric: tabular-nums;
}
@@ -1853,6 +1857,7 @@ body:focus-visible,
.media-timing-review-confirm-button,
.media-timing-review-quiet-button,
.media-timing-review-original-button,
.media-timing-review-skip-button,
.media-timing-review-discard-button {
padding: 9px 15px;
}
@@ -1897,6 +1902,11 @@ body:focus-visible,
color: var(--ctp-yellow);
}
.media-timing-review-skip-button {
border-color: var(--ctp-sky);
color: var(--ctp-sky);
}
.media-timing-review-discard-button {
border-color: var(--ctp-red);
background: var(--ctp-red);
+2
View File
@@ -75,6 +75,7 @@ export type RendererDom = {
mediaTimingReviewCancelMessage: HTMLParagraphElement;
mediaTimingReviewCancelBack: HTMLButtonElement;
mediaTimingReviewUseOriginal: HTMLButtonElement;
mediaTimingReviewSkipMedia: HTMLButtonElement;
mediaTimingReviewDiscard: HTMLButtonElement;
kikuModal: HTMLDivElement;
@@ -292,6 +293,7 @@ export function resolveRendererDom(): RendererDom {
mediaTimingReviewUseOriginal: getRequiredElement<HTMLButtonElement>(
'mediaTimingReviewUseOriginal',
),
mediaTimingReviewSkipMedia: getRequiredElement<HTMLButtonElement>('mediaTimingReviewSkipMedia'),
mediaTimingReviewDiscard: getRequiredElement<HTMLButtonElement>('mediaTimingReviewDiscard'),
kikuModal: getRequiredElement<HTMLDivElement>('kikuFieldGroupingModal'),
+1
View File
@@ -26,6 +26,7 @@ export interface MediaTimingReviewRequest {
export type MediaTimingReviewDecision =
| { action: 'confirm'; startTime: number; endTime: number }
| { action: 'use-original' }
| { action: 'skip-media' }
| { action: 'discard' };
export interface MediaTimingReviewOpenPayload {