fix(anki): report stale timing reviews after preview and waveform awaits

Playback and ffmpeg waveform analysis both span long enough for the
review to end underneath them (decision watchdog, overlay teardown).
Both paths reported success or a generic failure afterwards, so the
modal stayed open on a review main had already dropped.
This commit is contained in:
2026-09-03 23:12:24 -07:00
parent 144db675e9
commit a610c90c25
2 changed files with 53 additions and 2 deletions
+37 -1
View File
@@ -78,6 +78,8 @@ async function startActiveMediaTimingReview(
options: {
maxMediaDuration?: number;
decisionTimeoutMs?: number;
generateWaveform?: () => Promise<number[]>;
play?: () => Promise<void>;
} = {},
) {
const previewCalls: Array<[number, number]> = [];
@@ -94,12 +96,13 @@ async function startActiveMediaTimingReview(
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
generateWaveform: options.generateWaveform ?? (async () => []),
decisionTimeoutMs: options.decisionTimeoutMs,
createPreviewSession: () => ({
start: async () => undefined,
play: async (startTime, endTime) => {
previewCalls.push([startTime, endTime]);
await options.play?.();
},
stop: async () => undefined,
onPlaybackEnded: () => undefined,
@@ -821,3 +824,36 @@ test('media timing review forwards the hidden player finishing a preview to the
playback.ended();
assert.deepEqual(endedReviewIds, [payload.reviewId]);
});
test('preview reports a stale review when the review ends during playback', async () => {
let endReview: (() => Promise<void>) | null = null;
const { runtime, payload, pendingDecision } = await startActiveMediaTimingReview({
play: async () => {
await endReview?.();
},
});
endReview = () => runtime.dispose();
assert.deepEqual(
await runtime.previewRange({ reviewId: payload.reviewId, startTime: 10, endTime: 12 }),
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
);
await pendingDecision;
});
test('waveform reports a stale review when the review ends during analysis', async () => {
let endReview: (() => Promise<void>) | null = null;
const { runtime, payload, pendingDecision } = await startActiveMediaTimingReview({
generateWaveform: async () => {
await endReview?.();
return [0.1, 0.9, 0.2];
},
});
endReview = () => runtime.dispose();
assert.deepEqual(
await runtime.getWaveform({ reviewId: payload.reviewId, startTime: 8, endTime: 14 }),
{ ok: false, stale: true, message: 'This timing review is no longer active.' },
);
await pendingDecision;
});
+16 -1
View File
@@ -463,8 +463,16 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
return staleReviewResult();
}
await previewSession.play(request.startTime, request.endTime);
// Playback spans the whole clip, so the review can end (watchdog, teardown) while
// it runs; reporting success would leave the modal open on a dead review.
if (active !== current) {
return staleReviewResult();
}
return { ok: true };
} catch (error) {
if (active !== current) {
return staleReviewResult();
}
return {
ok: false,
message: `Audio preview unavailable: ${error instanceof Error ? error.message : String(error)}`,
@@ -503,11 +511,18 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
? { audioStreamIndex: current.audioStreamIndex }
: {}),
});
if (active !== current || peaks.length < 2 || peaks.some((peak) => !Number.isFinite(peak))) {
// ffmpeg decoding runs long enough for the review to end underneath it.
if (active !== current) {
return staleReviewResult();
}
if (peaks.length < 2 || peaks.some((peak) => !Number.isFinite(peak))) {
return { ok: false, message: 'Timing waveform is unavailable.' };
}
return { ok: true, peaks };
} catch {
if (active !== current) {
return staleReviewResult();
}
return { ok: false, message: 'Timing waveform is unavailable.' };
}
}