mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-12 05:16:19 -07:00
feat(anki): add multi-line subtitle selection to media timing review
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildMediaTimingLineSelection,
|
||||
buildMediaTimingWaveformPath,
|
||||
constrainMediaTimingSelection,
|
||||
createMediaTimingPreviewRequestGuard,
|
||||
@@ -73,6 +74,35 @@ test('sliding keeps the clip length and stops at the timeline and media bounds',
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { MediaTimingReviewDecision, MediaTimingReviewOpenPayload } from '../../types/anki';
|
||||
import type {
|
||||
MediaTimingReviewContextLine,
|
||||
MediaTimingReviewDecision,
|
||||
MediaTimingReviewOpenPayload,
|
||||
} from '../../types/anki';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { createModalFocusGuard } from './modal-focus-guard';
|
||||
|
||||
@@ -6,6 +10,7 @@ const MINIMUM_CLIP_SECONDS = 0.1;
|
||||
const FINE_ADJUST_SECONDS = 0.1;
|
||||
const COARSE_ADJUST_SECONDS = 0.5;
|
||||
const TIMELINE_EXPANSION_SECONDS = 2;
|
||||
const LINE_REVEAL_MARGIN_SECONDS = 1;
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
@@ -23,6 +28,43 @@ export function formatMediaTimingTimestamp(seconds: number, includeMilliseconds
|
||||
return `${String(minutes).padStart(2, '0')}:${remaining.padStart(includeMilliseconds ? 6 : 2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which subtitle lines the card sentence currently includes. The counts say
|
||||
* how many adjacent lines were pulled in on each side; the range covers those lines'
|
||||
* subtitle timings so the clip edges can follow them.
|
||||
*/
|
||||
export function buildMediaTimingLineSelection(options: {
|
||||
previousLines: MediaTimingReviewContextLine[];
|
||||
nextLines: MediaTimingReviewContextLine[];
|
||||
text: string;
|
||||
originalStartTime: number;
|
||||
originalEndTime: number;
|
||||
previousCount: number;
|
||||
nextCount: number;
|
||||
}): {
|
||||
lineTexts: string[];
|
||||
currentLineIndex: number;
|
||||
sentence: string;
|
||||
rangeStart: number;
|
||||
rangeEnd: number;
|
||||
} {
|
||||
const previous =
|
||||
options.previousCount > 0 ? options.previousLines.slice(-options.previousCount) : [];
|
||||
const next = options.nextCount > 0 ? options.nextLines.slice(0, options.nextCount) : [];
|
||||
const lineTexts = [
|
||||
...previous.map((line) => line.text),
|
||||
options.text,
|
||||
...next.map((line) => line.text),
|
||||
];
|
||||
return {
|
||||
lineTexts,
|
||||
currentLineIndex: previous.length,
|
||||
sentence: lineTexts.join(' '),
|
||||
rangeStart: previous[0]?.startTime ?? options.originalStartTime,
|
||||
rangeEnd: next[next.length - 1]?.endTime ?? options.originalEndTime,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMediaTimingPreviewRequestGuard() {
|
||||
let sequence = 0;
|
||||
let activeRequestId: number | null = null;
|
||||
@@ -128,6 +170,10 @@ export function createMediaTimingReviewModal(
|
||||
let selectionEnd = 0;
|
||||
let timelineStart = 0;
|
||||
let timelineEnd = 0;
|
||||
let previousCount = 0;
|
||||
let nextCount = 0;
|
||||
let startPadSeconds = 0;
|
||||
let endPadSeconds = 0;
|
||||
let resolveInFlight = false;
|
||||
let previewPlaying = false;
|
||||
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -304,6 +350,86 @@ export function createMediaTimingReviewModal(
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function currentLineSelection(): ReturnType<typeof buildMediaTimingLineSelection> {
|
||||
return buildMediaTimingLineSelection({
|
||||
previousLines: payload?.previousLines ?? [],
|
||||
nextLines: payload?.nextLines ?? [],
|
||||
text: payload?.text ?? '',
|
||||
originalStartTime: payload?.originalStartTime ?? 0,
|
||||
originalEndTime: payload?.originalEndTime ?? 0,
|
||||
previousCount,
|
||||
nextCount,
|
||||
});
|
||||
}
|
||||
|
||||
function renderSentence(): void {
|
||||
if (!payload) return;
|
||||
const selection = currentLineSelection();
|
||||
ctx.dom.mediaTimingReviewText.replaceChildren(
|
||||
...selection.lineTexts.map((text, index) => {
|
||||
const line = document.createElement('span');
|
||||
line.className =
|
||||
index === selection.currentLineIndex
|
||||
? 'media-timing-review-line is-current'
|
||||
: 'media-timing-review-line';
|
||||
line.textContent = text;
|
||||
return line;
|
||||
}),
|
||||
);
|
||||
const total = selection.lineTexts.length;
|
||||
ctx.dom.mediaTimingReviewLineCount.textContent = total === 1 ? '1 line' : `${total} lines`;
|
||||
const hasContext = payload.previousLines.length > 0 || payload.nextLines.length > 0;
|
||||
ctx.dom.mediaTimingReviewLineControls.classList.toggle('hidden', !hasContext);
|
||||
ctx.dom.mediaTimingReviewPrevAdd.disabled = previousCount >= payload.previousLines.length;
|
||||
ctx.dom.mediaTimingReviewPrevRemove.disabled = previousCount <= 0;
|
||||
ctx.dom.mediaTimingReviewNextAdd.disabled = nextCount >= payload.nextLines.length;
|
||||
ctx.dom.mediaTimingReviewNextRemove.disabled = nextCount <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or removes an adjacent subtitle line from the card sentence, then follows the
|
||||
* affected clip edge to the new outermost line while keeping the user's other edge.
|
||||
*/
|
||||
function adjustLines(direction: 'previous' | 'next', delta: number): void {
|
||||
if (!payload || resolveInFlight) return;
|
||||
const available =
|
||||
direction === 'previous' ? payload.previousLines.length : payload.nextLines.length;
|
||||
const current = direction === 'previous' ? previousCount : nextCount;
|
||||
const updated = clamp(current + delta, 0, available);
|
||||
if (updated === current) return;
|
||||
if (direction === 'previous') previousCount = updated;
|
||||
else nextCount = updated;
|
||||
|
||||
const selection = currentLineSelection();
|
||||
const mediaEnd = payload.mediaDuration ?? Number.POSITIVE_INFINITY;
|
||||
let timelineChanged = false;
|
||||
let capped = false;
|
||||
if (direction === 'previous') {
|
||||
const target = Math.max(0, selection.rangeStart - startPadSeconds);
|
||||
if (target < timelineStart) {
|
||||
timelineStart = Math.max(0, target - LINE_REVEAL_MARGIN_SECONDS);
|
||||
timelineChanged = true;
|
||||
}
|
||||
updateSelection(target, selectionEnd);
|
||||
capped = selectionStart > target + 0.001;
|
||||
} else {
|
||||
const target = Math.min(mediaEnd, selection.rangeEnd + endPadSeconds);
|
||||
if (target > timelineEnd) {
|
||||
timelineEnd = Math.min(mediaEnd, target + LINE_REVEAL_MARGIN_SECONDS);
|
||||
timelineChanged = true;
|
||||
}
|
||||
updateSelection(selectionStart, target);
|
||||
capped = selectionEnd < target - 0.001;
|
||||
}
|
||||
renderSentence();
|
||||
if (capped && payload.maxMediaDuration > 0) {
|
||||
setStatus(
|
||||
`Clip length is capped at ${payload.maxMediaDuration}s, so the audio cannot cover every added line.`,
|
||||
);
|
||||
}
|
||||
if (timelineChanged) queueWaveformLoad(120);
|
||||
}
|
||||
|
||||
function updateSelection(nextStart: number, nextEnd: number): void {
|
||||
if (!payload) return;
|
||||
const mediaEnd = payload.mediaDuration ?? Number.POSITIVE_INFINITY;
|
||||
@@ -507,9 +633,21 @@ export function createMediaTimingReviewModal(
|
||||
button.disabled = false;
|
||||
});
|
||||
renderSelection();
|
||||
renderSentence();
|
||||
}
|
||||
}
|
||||
|
||||
function confirmSelection(): void {
|
||||
if (!payload) return;
|
||||
const includesAdjacentLines = previousCount > 0 || nextCount > 0;
|
||||
void resolveReview({
|
||||
action: 'confirm',
|
||||
startTime: selectionStart,
|
||||
endTime: selectionEnd,
|
||||
...(includesAdjacentLines ? { text: currentLineSelection().sentence } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function togglePreview(): Promise<void> {
|
||||
if (!payload || resolveInFlight) return;
|
||||
if (previewPlaying) {
|
||||
@@ -554,11 +692,19 @@ export function createMediaTimingReviewModal(
|
||||
function openMediaTimingReviewModal(nextPayload: MediaTimingReviewOpenPayload): void {
|
||||
previewRequest.invalidate();
|
||||
cancelDrag();
|
||||
payload = nextPayload;
|
||||
payload = {
|
||||
...nextPayload,
|
||||
previousLines: nextPayload.previousLines ?? [],
|
||||
nextLines: nextPayload.nextLines ?? [],
|
||||
};
|
||||
selectionStart = nextPayload.selectionStartTime;
|
||||
selectionEnd = nextPayload.selectionEndTime;
|
||||
timelineStart = nextPayload.timelineStartTime;
|
||||
timelineEnd = nextPayload.timelineEndTime;
|
||||
previousCount = 0;
|
||||
nextCount = 0;
|
||||
startPadSeconds = Math.max(0, nextPayload.originalStartTime - nextPayload.selectionStartTime);
|
||||
endPadSeconds = Math.max(0, nextPayload.selectionEndTime - nextPayload.originalEndTime);
|
||||
resolveInFlight = false;
|
||||
setPreviewPlaying(false);
|
||||
ctx.dom.mediaTimingReviewKind.textContent =
|
||||
@@ -568,12 +714,12 @@ export function createMediaTimingReviewModal(
|
||||
? 'Audio card'
|
||||
: 'Sentence card';
|
||||
ctx.dom.mediaTimingReviewKind.dataset.kind = nextPayload.kind;
|
||||
ctx.dom.mediaTimingReviewText.textContent = nextPayload.text;
|
||||
ctx.dom.mediaTimingReviewDiscard.textContent =
|
||||
nextPayload.noteId !== undefined ? 'Delete card' : "Don't create card";
|
||||
setStatus('');
|
||||
showEditor();
|
||||
renderSelection();
|
||||
renderSentence();
|
||||
ctx.state.mediaTimingReviewModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
@@ -625,7 +771,21 @@ export function createMediaTimingReviewModal(
|
||||
!(event.target instanceof Element && event.target.closest('button'))
|
||||
) {
|
||||
event.preventDefault();
|
||||
void resolveReview({ action: 'confirm', startTime: selectionStart, endTime: selectionEnd });
|
||||
confirmSelection();
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(event.key === 'p' || event.key === 'P' || event.key === 'n' || event.key === 'N') &&
|
||||
ctx.dom.mediaTimingReviewCancelStep.classList.contains('hidden') &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
event.preventDefault();
|
||||
adjustLines(
|
||||
event.key === 'p' || event.key === 'P' ? 'previous' : 'next',
|
||||
event.shiftKey ? -1 : 1,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -662,8 +822,17 @@ export function createMediaTimingReviewModal(
|
||||
ctx.dom.mediaTimingReviewPlay.addEventListener('click', () => void togglePreview());
|
||||
ctx.dom.mediaTimingReviewReset.addEventListener('click', () => {
|
||||
if (!payload) return;
|
||||
previousCount = 0;
|
||||
nextCount = 0;
|
||||
updateSelection(payload.selectionStartTime, payload.selectionEndTime);
|
||||
renderSentence();
|
||||
});
|
||||
ctx.dom.mediaTimingReviewPrevAdd.addEventListener('click', () => adjustLines('previous', 1));
|
||||
ctx.dom.mediaTimingReviewPrevRemove.addEventListener('click', () =>
|
||||
adjustLines('previous', -1),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewNextAdd.addEventListener('click', () => adjustLines('next', 1));
|
||||
ctx.dom.mediaTimingReviewNextRemove.addEventListener('click', () => adjustLines('next', -1));
|
||||
ctx.dom.mediaTimingReviewCancel.addEventListener('click', requestCancel);
|
||||
ctx.dom.mediaTimingReviewCancelBack.addEventListener('click', showEditor);
|
||||
ctx.dom.mediaTimingReviewUseOriginal.addEventListener(
|
||||
@@ -678,11 +847,7 @@ export function createMediaTimingReviewModal(
|
||||
'click',
|
||||
() => void resolveReview({ action: 'discard' }),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewConfirm.addEventListener(
|
||||
'click',
|
||||
() =>
|
||||
void resolveReview({ action: 'confirm', startTime: selectionStart, endTime: selectionEnd }),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewConfirm.addEventListener('click', () => confirmSelection());
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user