mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-30 12:15:26 -07:00
feat(anki): add multi-line subtitle selection to media timing review
This commit is contained in:
@@ -2,3 +2,4 @@ type: added
|
||||
area: mining
|
||||
|
||||
- Added optional pre-generation timing review for word, sentence, and audio cards with a compact speech-weighted waveform, clearly labeled mined-line boundaries, drag and keyboard adjustments, audio preview with a sweeping playhead, exact screenshot and AVIF timing, cancellation choices that include keeping a card without media, and a session-only runtime toggle.
|
||||
- The timing review can pull any number of previous and next subtitle lines onto the card: `P`/`N` (or the Prev/Next steppers) add lines one at a time, Shift removes them, the sentence preview highlights exactly what the card will contain, and the clip range follows the added lines automatically.
|
||||
|
||||
@@ -183,6 +183,8 @@ The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<time
|
||||
|
||||
Set `media.reviewTiming` to `true` to pause playback and review each word, sentence, or audio card before its media is generated. The review opens with the subtitle range plus configured audio padding. Drag either edge of the clip to trim it, drag the middle to slide it without changing its length, or press anywhere else on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys, by 100 ms alone or 500 ms with Shift, and the 100 ms buttons do the same. Space previews the selection with a playhead that sweeps the clip, Enter confirms, and Escape cancels. The Earlier and Later buttons reveal another two seconds of available timeline without moving the selected clip. A speech-weighted waveform shows the mined subtitle as a tinted band with labeled line-start and line-end rails, making adjacent dialogue easier to distinguish. SubMiner uses a center channel when one carries dialogue, then falls back to a speech-band mono mix. Waveform analysis failure leaves the timing controls available. The confirmed range is exact: SubMiner does not apply audio padding a second time. Static screenshots use its midpoint, and animated AVIF clips use the full confirmed range.
|
||||
|
||||
The review can also pull adjacent subtitle lines onto the card. Press `P` or `N` (or use the Prev and Next steppers above the sentence preview) to add the previous or next line, as many times as lines are available; Shift+`P` and Shift+`N` remove them again. The sentence preview lists every included line with the mined line highlighted, so the card's sentence field is always visible before you confirm, and the clip start or end follows the outermost added line, keeping the review's audio padding. Confirming writes the combined lines to the sentence field; the Reset button drops the added lines along with any timing changes. Adjacent lines come from the parsed subtitle track when one is loaded; otherwise only lines that already played are offered, and a clip capped by `media.maxMediaDuration` keeps the full combined sentence even when the audio cannot cover every added line.
|
||||
|
||||
Canceling the review lets you keep editing, finish with the original timing, keep or create the card without audio or an image, or discard the card. Discard deletes an existing Yomitan or audio card and skips creation for a direct sentence card. Clipboard updates and stats-dashboard mining do not open timing review. Audio preview failure does not block confirmation or card creation. The option is disabled by default and hot-reloads. You can also toggle **Review Media Timing** for the current session from the runtime options palette (`Ctrl/Cmd+Shift+O`).
|
||||
|
||||
### Screenshots (Static)
|
||||
|
||||
@@ -485,9 +485,11 @@ export class CardCreationService {
|
||||
}
|
||||
const skipMedia = timingDecision.action === 'skip-media';
|
||||
const exactReviewedRange = timingDecision.action === 'confirm';
|
||||
let sentenceText = mpvClient.currentSubText;
|
||||
if (timingDecision.action === 'confirm') {
|
||||
startTime = timingDecision.startTime;
|
||||
endTime = timingDecision.endTime;
|
||||
sentenceText = timingDecision.text?.trim() || sentenceText;
|
||||
}
|
||||
|
||||
const updatedFields: Record<string, string> = {};
|
||||
@@ -499,7 +501,7 @@ export class CardCreationService {
|
||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||
const sentenceField = sentenceCardConfig.sentenceField;
|
||||
if (sentenceField) {
|
||||
const processedSentence = this.deps.processSentence(mpvClient.currentSubText, fields);
|
||||
const processedSentence = this.deps.processSentence(sentenceText, fields);
|
||||
updatedFields[sentenceField] = processedSentence;
|
||||
}
|
||||
|
||||
@@ -624,6 +626,7 @@ export class CardCreationService {
|
||||
if (timingDecision.action === 'confirm') {
|
||||
startTime = timingDecision.startTime;
|
||||
endTime = timingDecision.endTime;
|
||||
sentence = timingDecision.text?.trim() || sentence;
|
||||
}
|
||||
|
||||
const config = this.deps.getConfig();
|
||||
|
||||
@@ -710,6 +710,43 @@ test('NoteUpdateWorkflow keeps the word card but skips media after timing review
|
||||
assert.deepEqual(harness.notifications, [{ noteId: 42, label: 'taberu' }]);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow uses the combined review sentence for the card and media range', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
const audioContexts: Array<SubtitleMiningContext | undefined> = [];
|
||||
harness.deps.captureSubtitleMediaContext = () => ({
|
||||
source: 'overlay',
|
||||
text: 'current-line',
|
||||
startTime: 4,
|
||||
endTime: 6,
|
||||
});
|
||||
harness.deps.getConfig = () => ({
|
||||
fields: { sentence: 'Sentence' },
|
||||
media: { generateAudio: true, generateImage: false },
|
||||
behavior: {},
|
||||
});
|
||||
harness.deps.reviewMediaTiming = async () => ({
|
||||
action: 'confirm',
|
||||
startTime: 2,
|
||||
endTime: 7,
|
||||
text: 'previous-line current-line next-line',
|
||||
});
|
||||
harness.deps.generateAudio = async (context) => {
|
||||
audioContexts.push(context);
|
||||
return null;
|
||||
};
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.deepEqual(harness.updates, [
|
||||
{ noteId: 42, fields: { Sentence: 'previous-line current-line next-line' } },
|
||||
]);
|
||||
assert.equal(audioContexts.length, 1);
|
||||
assert.equal(audioContexts[0]?.text, 'previous-line current-line next-line');
|
||||
assert.equal(audioContexts[0]?.startTime, 2);
|
||||
assert.equal(audioContexts[0]?.endTime, 7);
|
||||
assert.equal(audioContexts[0]?.mediaPaddingSeconds, 0);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
const statusMessages: string[] = [];
|
||||
|
||||
@@ -221,6 +221,7 @@ export class NoteUpdateWorkflow {
|
||||
let mediaTimingContext =
|
||||
subtitleMiningContext ?? this.deps.captureSubtitleMediaContext?.() ?? null;
|
||||
let skipMedia = false;
|
||||
let reviewedSentenceText: string | undefined;
|
||||
const noteLabel = hasExpressionText ? expressionText : noteId;
|
||||
|
||||
if (mediaTimingContext) {
|
||||
@@ -247,8 +248,10 @@ export class NoteUpdateWorkflow {
|
||||
return;
|
||||
}
|
||||
if (timingDecision.action === 'confirm') {
|
||||
reviewedSentenceText = timingDecision.text?.trim() || undefined;
|
||||
mediaTimingContext = {
|
||||
...mediaTimingContext,
|
||||
...(reviewedSentenceText !== undefined ? { text: reviewedSentenceText } : {}),
|
||||
startTime: timingDecision.startTime,
|
||||
endTime: timingDecision.endTime,
|
||||
mediaPaddingSeconds: 0,
|
||||
@@ -260,7 +263,8 @@ export class NoteUpdateWorkflow {
|
||||
|
||||
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
||||
|
||||
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
|
||||
const currentSubtitleText =
|
||||
reviewedSentenceText ?? subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
|
||||
if (sentenceField && currentSubtitleText) {
|
||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||
updatedFields[sentenceField] = processedSentence;
|
||||
|
||||
+11
-1
@@ -467,7 +467,10 @@ import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
||||
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
|
||||
import { MediaTimingPreviewSession } from './core/services/media-timing-preview';
|
||||
import { generateSpeechWaveform } from './core/services/media-timing-waveform';
|
||||
import { createMediaTimingReviewRuntime } from './main/runtime/media-timing-review';
|
||||
import {
|
||||
collectMediaTimingContextLines,
|
||||
createMediaTimingReviewRuntime,
|
||||
} from './main/runtime/media-timing-review';
|
||||
import { openMediaTimingReviewModal } from './main/runtime/media-timing-review-open';
|
||||
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
||||
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
|
||||
@@ -2897,6 +2900,13 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
||||
configService.getConfig().mpv.executablePath || process.env.SUBMINER_MPV_PATH?.trim() || '',
|
||||
createPreviewSession: () => new MediaTimingPreviewSession(),
|
||||
generateWaveform: (options) => generateSpeechWaveform(options),
|
||||
getSubtitleContextLines: (range) =>
|
||||
collectMediaTimingContextLines({
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [],
|
||||
startTime: range.startTime,
|
||||
endTime: range.endTime,
|
||||
}),
|
||||
openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload),
|
||||
showStatus: (message) =>
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, test } from 'node:test';
|
||||
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
|
||||
import {
|
||||
buildMediaTimingReviewPayload,
|
||||
collectMediaTimingContextLines,
|
||||
createMediaTimingReviewRuntime,
|
||||
} from './media-timing-review';
|
||||
|
||||
@@ -268,6 +269,13 @@ test('media timing review rejects stale and out-of-range actions before allowing
|
||||
}),
|
||||
{ ok: false, message: 'The selected timing range is invalid.' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
runtime.resolveReview({
|
||||
reviewId: payload.reviewId,
|
||||
decision: { action: 'confirm', startTime: 10, endTime: 12, text: ' ' },
|
||||
}),
|
||||
{ ok: false, message: 'The combined sentence text is invalid.' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'discard' } }),
|
||||
{ ok: true },
|
||||
@@ -276,6 +284,43 @@ test('media timing review rejects stale and out-of-range actions before allowing
|
||||
assert.deepEqual(previewCalls, []);
|
||||
});
|
||||
|
||||
test('collectMediaTimingContextLines splits cues around the mined range', () => {
|
||||
const cues = [
|
||||
{ text: '一行目', startTime: 0, endTime: 2 },
|
||||
{ text: '二行目', startTime: 2.5, endTime: 4 },
|
||||
{ text: '', startTime: 4.2, endTime: 4.4 },
|
||||
{ text: '採掘行', startTime: 5, endTime: 7 },
|
||||
{ text: '四行目', startTime: 7.5, endTime: 9 },
|
||||
{ text: '五行目', startTime: 9.5, endTime: 11 },
|
||||
];
|
||||
|
||||
const context = collectMediaTimingContextLines({ cues, startTime: 5, endTime: 7 });
|
||||
|
||||
assert.deepEqual(context.previous, [
|
||||
{ text: '一行目', startTime: 0, endTime: 2 },
|
||||
{ text: '二行目', startTime: 2.5, endTime: 4 },
|
||||
]);
|
||||
assert.deepEqual(context.next, [
|
||||
{ text: '四行目', startTime: 7.5, endTime: 9 },
|
||||
{ text: '五行目', startTime: 9.5, endTime: 11 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('collectMediaTimingContextLines falls back to played history when no cues are loaded', () => {
|
||||
const context = collectMediaTimingContextLines({
|
||||
cues: [],
|
||||
fallbackPrevious: [
|
||||
{ displayText: '前の行', startTime: 1, endTime: 2 },
|
||||
{ displayText: '採掘行', startTime: 5, endTime: 7 },
|
||||
],
|
||||
startTime: 5,
|
||||
endTime: 7,
|
||||
});
|
||||
|
||||
assert.deepEqual(context.previous, [{ text: '前の行', startTime: 1, endTime: 2 }]);
|
||||
assert.deepEqual(context.next, []);
|
||||
});
|
||||
|
||||
test('media timing review watchdog falls back when the renderer stops responding', async () => {
|
||||
const { pendingDecision } = await startActiveMediaTimingReview({ decisionTimeoutMs: 0 });
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import type {
|
||||
MediaTimingReviewActionResult,
|
||||
MediaTimingReviewContextLine,
|
||||
MediaTimingReviewDecision,
|
||||
MediaTimingReviewOpenPayload,
|
||||
MediaTimingReviewPreviewRequest,
|
||||
@@ -13,6 +14,8 @@ import type { SpeechWaveformOptions } from '../../core/services/media-timing-wav
|
||||
|
||||
const INITIAL_TIMELINE_MARGIN_SECONDS = 2;
|
||||
const REVIEW_DECISION_TIMEOUT_MS = 5 * 60_000;
|
||||
const CONTEXT_LINE_LIMIT = 12;
|
||||
const CONTEXT_LINE_EPSILON_SECONDS = 0.05;
|
||||
|
||||
interface ReviewMpvClient {
|
||||
connected: boolean;
|
||||
@@ -50,6 +53,10 @@ export interface MediaTimingReviewRuntimeDeps {
|
||||
getMpvExecutablePath: () => string;
|
||||
createPreviewSession: () => PreviewSession;
|
||||
generateWaveform: (options: SpeechWaveformOptions) => Promise<number[]>;
|
||||
getSubtitleContextLines?: (range: { startTime: number; endTime: number }) => {
|
||||
previous: MediaTimingReviewContextLine[];
|
||||
next: MediaTimingReviewContextLine[];
|
||||
};
|
||||
decisionTimeoutMs?: number;
|
||||
openModal: (payload: MediaTimingReviewOpenPayload) => Promise<boolean>;
|
||||
showStatus: (message: string) => void;
|
||||
@@ -66,6 +73,57 @@ function booleanProperty(value: unknown): boolean | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the subtitle lines adjacent to the mined range that the review modal can pull
|
||||
* onto the card. Parsed cues cover both directions; when none are loaded (e.g. the
|
||||
* active track was never parsed) the timing tracker's history still provides the
|
||||
* lines that already played, so only "next" is unavailable.
|
||||
*/
|
||||
export function collectMediaTimingContextLines(options: {
|
||||
cues: readonly { text: string; startTime: number; endTime: number }[];
|
||||
fallbackPrevious?: readonly { displayText: string; startTime: number; endTime: number }[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}): { previous: MediaTimingReviewContextLine[]; next: MediaTimingReviewContextLine[] } {
|
||||
const usable = options.cues
|
||||
.filter(
|
||||
(cue) =>
|
||||
cue.text.trim().length > 0 &&
|
||||
Number.isFinite(cue.startTime) &&
|
||||
Number.isFinite(cue.endTime) &&
|
||||
cue.endTime > cue.startTime,
|
||||
)
|
||||
.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime);
|
||||
|
||||
let previous = usable
|
||||
.filter((cue) => cue.endTime <= options.startTime + CONTEXT_LINE_EPSILON_SECONDS)
|
||||
.slice(-CONTEXT_LINE_LIMIT)
|
||||
.map(({ text, startTime, endTime }) => ({ text: text.trim(), startTime, endTime }));
|
||||
const next = usable
|
||||
.filter((cue) => cue.startTime >= options.endTime - CONTEXT_LINE_EPSILON_SECONDS)
|
||||
.slice(0, CONTEXT_LINE_LIMIT)
|
||||
.map(({ text, startTime, endTime }) => ({ text: text.trim(), startTime, endTime }));
|
||||
|
||||
if (previous.length === 0 && options.fallbackPrevious) {
|
||||
previous = options.fallbackPrevious
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.displayText.trim().length > 0 &&
|
||||
Number.isFinite(entry.startTime) &&
|
||||
Number.isFinite(entry.endTime) &&
|
||||
entry.endTime > entry.startTime &&
|
||||
entry.endTime <= options.startTime + CONTEXT_LINE_EPSILON_SECONDS,
|
||||
)
|
||||
.slice(-CONTEXT_LINE_LIMIT)
|
||||
.map((entry) => ({
|
||||
text: entry.displayText.trim(),
|
||||
startTime: entry.startTime,
|
||||
endTime: entry.endTime,
|
||||
}));
|
||||
}
|
||||
return { previous, next };
|
||||
}
|
||||
|
||||
function isValidMediaTimingRange(
|
||||
payload: MediaTimingReviewOpenPayload,
|
||||
startTime: number,
|
||||
@@ -83,7 +141,14 @@ function isValidMediaTimingRange(
|
||||
|
||||
export function buildMediaTimingReviewPayload(
|
||||
request: MediaTimingReviewRequest,
|
||||
options: { reviewId: string; mediaDuration?: number },
|
||||
options: {
|
||||
reviewId: string;
|
||||
mediaDuration?: number;
|
||||
contextLines?: {
|
||||
previous: MediaTimingReviewContextLine[];
|
||||
next: MediaTimingReviewContextLine[];
|
||||
};
|
||||
},
|
||||
): MediaTimingReviewOpenPayload {
|
||||
const duration = finiteNumber(options.mediaDuration);
|
||||
const maxTime = duration !== null && duration > 0 ? duration : Number.POSITIVE_INFINITY;
|
||||
@@ -107,6 +172,8 @@ export function buildMediaTimingReviewPayload(
|
||||
reviewId: options.reviewId,
|
||||
kind: request.kind,
|
||||
text: request.text,
|
||||
previousLines: options.contextLines?.previous ?? [],
|
||||
nextLines: options.contextLines?.next ?? [],
|
||||
...(request.noteId !== undefined ? { noteId: request.noteId } : {}),
|
||||
originalStartTime: request.startTime,
|
||||
originalEndTime: request.endTime,
|
||||
@@ -151,9 +218,19 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
|
||||
pendingPauseRestore = pauseState === false ? mpvClient : null;
|
||||
|
||||
let contextLines: ReturnType<NonNullable<typeof deps.getSubtitleContextLines>> | undefined;
|
||||
try {
|
||||
contextLines = deps.getSubtitleContextLines?.({
|
||||
startTime: request.startTime,
|
||||
endTime: request.endTime,
|
||||
});
|
||||
} catch {
|
||||
contextLines = undefined;
|
||||
}
|
||||
const payload = buildMediaTimingReviewPayload(request, {
|
||||
reviewId: randomUUID(),
|
||||
mediaDuration: finiteNumber(durationRaw) ?? undefined,
|
||||
...(contextLines ? { contextLines } : {}),
|
||||
});
|
||||
const previewSession = deps.createPreviewSession();
|
||||
const preview = previewSession
|
||||
@@ -310,10 +387,13 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
||||
return { ok: false, message: 'This timing review is no longer active.' };
|
||||
}
|
||||
if (request.decision.action === 'confirm') {
|
||||
const { startTime, endTime } = request.decision;
|
||||
const { startTime, endTime, text } = request.decision;
|
||||
if (!isValidMediaTimingRange(current.payload, startTime, endTime)) {
|
||||
return { ok: false, message: 'The selected timing range is invalid.' };
|
||||
}
|
||||
if (text !== undefined && (typeof text !== 'string' || text.trim().length === 0)) {
|
||||
return { ok: false, message: 'The combined sentence text is invalid.' };
|
||||
}
|
||||
}
|
||||
current.resolve(request.decision);
|
||||
return { ok: true };
|
||||
|
||||
@@ -224,6 +224,57 @@
|
||||
</div>
|
||||
|
||||
<div id="mediaTimingReviewEditor" class="media-timing-review-editor">
|
||||
<div class="media-timing-review-sentence-header">
|
||||
<div class="media-timing-review-sentence-heading">
|
||||
<span class="media-timing-review-sentence-label">Sentence on card</span>
|
||||
<span id="mediaTimingReviewLineCount" class="media-timing-review-line-count">
|
||||
1 line
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
id="mediaTimingReviewLineControls"
|
||||
class="media-timing-review-line-controls hidden"
|
||||
>
|
||||
<div class="media-timing-review-line-stepper">
|
||||
<span>Prev</span>
|
||||
<button
|
||||
id="mediaTimingReviewPrevRemove"
|
||||
type="button"
|
||||
aria-label="Remove the earliest added previous subtitle line from the card sentence"
|
||||
title="Remove the earliest previous line (Shift+P)"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
id="mediaTimingReviewPrevAdd"
|
||||
type="button"
|
||||
aria-label="Add the previous subtitle line to the card sentence"
|
||||
title="Add the previous line (P)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div class="media-timing-review-line-stepper">
|
||||
<span>Next</span>
|
||||
<button
|
||||
id="mediaTimingReviewNextRemove"
|
||||
type="button"
|
||||
aria-label="Remove the latest added next subtitle line from the card sentence"
|
||||
title="Remove the latest next line (Shift+N)"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
id="mediaTimingReviewNextAdd"
|
||||
type="button"
|
||||
aria-label="Add the next subtitle line to the card sentence"
|
||||
title="Add the next line (N)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<blockquote id="mediaTimingReviewText" class="media-timing-review-text"></blockquote>
|
||||
|
||||
<div class="media-timing-review-readout" aria-live="polite">
|
||||
@@ -395,6 +446,7 @@
|
||||
<div class="media-timing-review-hints" aria-hidden="true">
|
||||
<span><kbd>Space</kbd> preview</span>
|
||||
<span><kbd>←</kbd><kbd>→</kbd> nudge focused edge</span>
|
||||
<span><kbd>P</kbd>/<kbd>N</kbd> add prev/next line</span>
|
||||
<span><kbd>Enter</kbd> confirm</span>
|
||||
<span><kbd>Esc</kbd> cancel</span>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+89
-5
@@ -1420,11 +1420,82 @@ body:focus-visible,
|
||||
padding: 14px 20px 18px;
|
||||
}
|
||||
|
||||
.media-timing-review-sentence-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.media-timing-review-sentence-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.media-timing-review-sentence-label {
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.media-timing-review-line-count {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 999px;
|
||||
background: var(--ctp-mantle);
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.media-timing-review-line-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.media-timing-review-line-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.media-timing-review-line-stepper span {
|
||||
margin-right: 2px;
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.media-timing-review-line-stepper button {
|
||||
width: 24px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--ctp-surface2);
|
||||
border-radius: 6px;
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.media-timing-review-text {
|
||||
max-height: 88px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
max-height: 108px;
|
||||
overflow: auto;
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 14px;
|
||||
padding: 8px 14px;
|
||||
border-left: 3px solid var(--ctp-mauve);
|
||||
border-radius: 0 10px 10px 0;
|
||||
background: var(--ctp-mantle);
|
||||
@@ -1435,6 +1506,19 @@ body:focus-visible,
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.media-timing-review-line {
|
||||
color: var(--ctp-overlay1);
|
||||
}
|
||||
|
||||
.media-timing-review-line.is-current {
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
/* Only dim context lines when some are actually added. */
|
||||
.media-timing-review-text .media-timing-review-line:only-child {
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.media-timing-review-readout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
@@ -1521,7 +1605,7 @@ body:focus-visible,
|
||||
--playhead-duration: 1s;
|
||||
|
||||
position: relative;
|
||||
height: 76px;
|
||||
height: 52px;
|
||||
margin: 8px 0 9px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ctp-surface2);
|
||||
@@ -1544,9 +1628,9 @@ body:focus-visible,
|
||||
.media-timing-review-waveform {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 6px 0;
|
||||
inset: 4px 0;
|
||||
width: 100%;
|
||||
height: calc(100% - 12px);
|
||||
height: calc(100% - 8px);
|
||||
pointer-events: none;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ export type RendererDom = {
|
||||
mediaTimingReviewModal: HTMLDivElement;
|
||||
mediaTimingReviewKind: HTMLDivElement;
|
||||
mediaTimingReviewText: HTMLElement;
|
||||
mediaTimingReviewLineCount: HTMLElement;
|
||||
mediaTimingReviewLineControls: HTMLDivElement;
|
||||
mediaTimingReviewPrevAdd: HTMLButtonElement;
|
||||
mediaTimingReviewPrevRemove: HTMLButtonElement;
|
||||
mediaTimingReviewNextAdd: HTMLButtonElement;
|
||||
mediaTimingReviewNextRemove: HTMLButtonElement;
|
||||
mediaTimingReviewStartValue: HTMLElement;
|
||||
mediaTimingReviewEndValue: HTMLElement;
|
||||
mediaTimingReviewDuration: HTMLElement;
|
||||
@@ -241,6 +247,18 @@ export function resolveRendererDom(): RendererDom {
|
||||
mediaTimingReviewModal: getRequiredElement<HTMLDivElement>('mediaTimingReviewModal'),
|
||||
mediaTimingReviewKind: getRequiredElement<HTMLDivElement>('mediaTimingReviewKind'),
|
||||
mediaTimingReviewText: getRequiredElement<HTMLElement>('mediaTimingReviewText'),
|
||||
mediaTimingReviewLineCount: getRequiredElement<HTMLElement>('mediaTimingReviewLineCount'),
|
||||
mediaTimingReviewLineControls: getRequiredElement<HTMLDivElement>(
|
||||
'mediaTimingReviewLineControls',
|
||||
),
|
||||
mediaTimingReviewPrevAdd: getRequiredElement<HTMLButtonElement>('mediaTimingReviewPrevAdd'),
|
||||
mediaTimingReviewPrevRemove: getRequiredElement<HTMLButtonElement>(
|
||||
'mediaTimingReviewPrevRemove',
|
||||
),
|
||||
mediaTimingReviewNextAdd: getRequiredElement<HTMLButtonElement>('mediaTimingReviewNextAdd'),
|
||||
mediaTimingReviewNextRemove: getRequiredElement<HTMLButtonElement>(
|
||||
'mediaTimingReviewNextRemove',
|
||||
),
|
||||
mediaTimingReviewStartValue: getRequiredElement<HTMLElement>('mediaTimingReviewStartValue'),
|
||||
mediaTimingReviewEndValue: getRequiredElement<HTMLElement>('mediaTimingReviewEndValue'),
|
||||
mediaTimingReviewDuration: getRequiredElement<HTMLElement>('mediaTimingReviewDuration'),
|
||||
|
||||
+12
-1
@@ -23,8 +23,16 @@ export interface MediaTimingReviewRequest {
|
||||
maxMediaDuration: number;
|
||||
}
|
||||
|
||||
/** A subtitle line adjacent to the mined one that the review can pull onto the card. */
|
||||
export interface MediaTimingReviewContextLine {
|
||||
text: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export type MediaTimingReviewDecision =
|
||||
| { action: 'confirm'; startTime: number; endTime: number }
|
||||
/** `text` is set when the review combined adjacent lines into the card sentence. */
|
||||
| { action: 'confirm'; startTime: number; endTime: number; text?: string }
|
||||
| { action: 'use-original' }
|
||||
| { action: 'skip-media' }
|
||||
| { action: 'discard' };
|
||||
@@ -33,6 +41,9 @@ export interface MediaTimingReviewOpenPayload {
|
||||
reviewId: string;
|
||||
kind: MediaTimingReviewKind;
|
||||
text: string;
|
||||
/** Lines before/after the mined one, both chronological: nearest previous line is last, nearest next line is first. */
|
||||
previousLines: MediaTimingReviewContextLine[];
|
||||
nextLines: MediaTimingReviewContextLine[];
|
||||
noteId?: number;
|
||||
originalStartTime: number;
|
||||
originalEndTime: number;
|
||||
|
||||
Reference in New Issue
Block a user