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:
@@ -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