mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-23 17:16:19 -07:00
feat(anki): add media timing review before card creation (#203)
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildMediaTimingLineSelection,
|
||||
buildMediaTimingWaveformPath,
|
||||
constrainMediaTimingSelection,
|
||||
createMediaTimingPreviewRequestGuard,
|
||||
formatMediaTimingTimestamp,
|
||||
mediaTimingTimeFromPointer,
|
||||
slideMediaTimingSelection,
|
||||
trimMediaTimingSelectionEnd,
|
||||
} from './media-timing-review';
|
||||
|
||||
test('waveform path mirrors normalized peaks around its center line', () => {
|
||||
const path = buildMediaTimingWaveformPath([0, 0.5, 1]);
|
||||
|
||||
assert.match(path, /^M 0\.00 50\.00 L 500\.00 28\.00 L 1000\.00 6\.00/);
|
||||
assert.match(path, /1000\.00 94\.00 L 500\.00 72\.00 L 0\.00 50\.00 Z$/);
|
||||
assert.equal(buildMediaTimingWaveformPath([1]), '');
|
||||
});
|
||||
|
||||
test('formatMediaTimingTimestamp renders stable review readouts', () => {
|
||||
assert.equal(formatMediaTimingTimestamp(65.4321), '01:05.432');
|
||||
assert.equal(formatMediaTimingTimestamp(65.4321, false), '01:05');
|
||||
assert.equal(formatMediaTimingTimestamp(-1), '00:00.000');
|
||||
assert.equal(formatMediaTimingTimestamp(119.9999), '02:00.000');
|
||||
assert.equal(formatMediaTimingTimestamp(119.6, false), '02:00');
|
||||
});
|
||||
|
||||
test('selection constraints preserve the handle that did not move', () => {
|
||||
assert.deepEqual(
|
||||
constrainMediaTimingSelection({
|
||||
nextStart: 3,
|
||||
nextEnd: 2,
|
||||
currentStart: 1,
|
||||
timelineStart: 0,
|
||||
timelineEnd: 10,
|
||||
mediaEnd: 10,
|
||||
maxMediaDuration: 30,
|
||||
}),
|
||||
{ start: 1.9, end: 2 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
constrainMediaTimingSelection({
|
||||
nextStart: 1,
|
||||
nextEnd: 0.5,
|
||||
currentStart: 1,
|
||||
timelineStart: 0,
|
||||
timelineEnd: 10,
|
||||
mediaEnd: 10,
|
||||
maxMediaDuration: 30,
|
||||
}),
|
||||
{ start: 1, end: 1.1 },
|
||||
);
|
||||
});
|
||||
|
||||
test('pointer positions map onto the visible timeline and clamp past its edges', () => {
|
||||
const track = { trackLeft: 100, trackWidth: 400, timelineStart: 10, timelineEnd: 20 };
|
||||
|
||||
assert.equal(mediaTimingTimeFromPointer({ clientX: 100, ...track }), 10);
|
||||
assert.equal(mediaTimingTimeFromPointer({ clientX: 300, ...track }), 15);
|
||||
assert.equal(mediaTimingTimeFromPointer({ clientX: -40, ...track }), 10);
|
||||
assert.equal(mediaTimingTimeFromPointer({ clientX: 900, ...track }), 20);
|
||||
assert.equal(mediaTimingTimeFromPointer({ ...track, clientX: 300, trackWidth: 0 }), 10);
|
||||
});
|
||||
|
||||
test('sliding keeps the clip length and stops at the timeline and media bounds', () => {
|
||||
const timeline = { span: 2, timelineStart: 4, timelineEnd: 12, mediaEnd: 10 };
|
||||
|
||||
assert.deepEqual(slideMediaTimingSelection({ nextStart: 6, ...timeline }), { start: 6, end: 8 });
|
||||
assert.deepEqual(slideMediaTimingSelection({ nextStart: 1, ...timeline }), { start: 4, end: 6 });
|
||||
assert.deepEqual(slideMediaTimingSelection({ nextStart: 99, ...timeline }), {
|
||||
start: 8,
|
||||
end: 10,
|
||||
});
|
||||
});
|
||||
|
||||
test('line selection combines adjacent lines around the mined one and tracks their range', () => {
|
||||
const base = {
|
||||
previousLines: [
|
||||
{ text: '一行目', startTime: 0, endTime: 2 },
|
||||
{ text: '二行目', startTime: 2.5, endTime: 4 },
|
||||
],
|
||||
nextLines: [
|
||||
{ text: '四行目', startTime: 7.5, endTime: 9 },
|
||||
{ text: '五行目', startTime: 9.5, endTime: 11 },
|
||||
],
|
||||
text: '採掘行',
|
||||
originalStartTime: 5,
|
||||
originalEndTime: 7,
|
||||
};
|
||||
|
||||
const none = buildMediaTimingLineSelection({ ...base, previousCount: 0, nextCount: 0 });
|
||||
assert.deepEqual(none.lineTexts, ['採掘行']);
|
||||
assert.equal(none.currentLineIndex, 0);
|
||||
assert.equal(none.rangeStart, 5);
|
||||
assert.equal(none.rangeEnd, 7);
|
||||
|
||||
const expanded = buildMediaTimingLineSelection({ ...base, previousCount: 1, nextCount: 2 });
|
||||
assert.deepEqual(expanded.lineTexts, ['二行目', '採掘行', '四行目', '五行目']);
|
||||
assert.equal(expanded.currentLineIndex, 1);
|
||||
assert.equal(expanded.sentence, '二行目 採掘行 四行目 五行目');
|
||||
assert.equal(expanded.rangeStart, 2.5);
|
||||
assert.equal(expanded.rangeEnd, 11);
|
||||
});
|
||||
|
||||
test('preview request guard blocks overlap and invalidates stale responses', () => {
|
||||
const guard = createMediaTimingPreviewRequestGuard();
|
||||
const first = guard.begin();
|
||||
|
||||
assert.equal(typeof first, 'number');
|
||||
assert.equal(guard.begin(), null);
|
||||
assert.equal(guard.isCurrent(first!), true);
|
||||
|
||||
guard.invalidate();
|
||||
assert.equal(guard.isCurrent(first!), false);
|
||||
assert.equal(guard.isInFlight(), false);
|
||||
|
||||
const second = guard.begin();
|
||||
assert.equal(typeof second, 'number');
|
||||
guard.finish(first!);
|
||||
assert.equal(guard.isCurrent(second!), true);
|
||||
guard.finish(second!);
|
||||
assert.equal(guard.isInFlight(), false);
|
||||
});
|
||||
|
||||
test('trailing silence trim follows the last speech slice and keeps cut-off or silent lines', () => {
|
||||
// 10 slices over a 10 s timeline: one slice per second, speech in seconds 2-4 only.
|
||||
const peaks = [0, 0, 0.9, 0.8, 0.7, 0.1, 0.2, 0, 0, 0];
|
||||
const base = { peaks, timelineStart: 0, timelineEnd: 10, lineStart: 2, lineEnd: 8 };
|
||||
|
||||
const trimmed = trimMediaTimingSelectionEnd({ ...base, selectionEnd: 8, endPadSeconds: 0 });
|
||||
assert.ok(trimmed !== null && Math.abs(trimmed - 5.15) < 1e-9);
|
||||
|
||||
const padded = trimMediaTimingSelectionEnd({ ...base, selectionEnd: 8.5, endPadSeconds: 0.5 });
|
||||
assert.ok(padded !== null && Math.abs(padded - 5.65) < 1e-9);
|
||||
|
||||
// Speech running through the line end means the subtitle cuts the audio off; keep it.
|
||||
assert.equal(
|
||||
trimMediaTimingSelectionEnd({ ...base, lineEnd: 5, selectionEnd: 5, endPadSeconds: 0 }),
|
||||
null,
|
||||
);
|
||||
// No speech inside the line at all: keep the subtitle timing rather than guess.
|
||||
assert.equal(
|
||||
trimMediaTimingSelectionEnd({ ...base, lineStart: 6, selectionEnd: 8, endPadSeconds: 0 }),
|
||||
null,
|
||||
);
|
||||
// A saving below the minimum trim is not worth moving the handle for.
|
||||
assert.equal(
|
||||
trimMediaTimingSelectionEnd({ ...base, lineEnd: 5.2, selectionEnd: 5.2, endPadSeconds: 0 }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
trimMediaTimingSelectionEnd({ ...base, peaks: [1], selectionEnd: 8, endPadSeconds: 0 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user