mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-30 12:15:26 -07:00
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:
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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
@@ -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."
|
||||
>
|
||||
−5s
|
||||
Earlier −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"
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user