feat(anki): add waveform-guided timing controls

- Add speech-weighted waveform analysis and playback playhead
- Support dragging, sliding, and keyboard nudging for clip timing
This commit is contained in:
2026-08-30 01:56:16 -07:00
parent 256eb5da1b
commit ca27a4275f
18 changed files with 1120 additions and 141 deletions
+23
View File
@@ -23,6 +23,8 @@ import type {
MediaTimingReviewActionResult,
MediaTimingReviewPreviewRequest,
MediaTimingReviewResolveRequest,
MediaTimingReviewWaveformRequest,
MediaTimingReviewWaveformResult,
} from '../../types/anki';
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
import {
@@ -107,6 +109,9 @@ export interface IpcServiceDeps {
previewMediaTimingReview?: (
request: MediaTimingReviewPreviewRequest,
) => Promise<MediaTimingReviewActionResult>;
getMediaTimingReviewWaveform?: (
request: MediaTimingReviewWaveformRequest,
) => Promise<MediaTimingReviewWaveformResult>;
stopMediaTimingReviewPreview?: (reviewId: string) => Promise<MediaTimingReviewActionResult>;
resolveMediaTimingReview?: (
request: MediaTimingReviewResolveRequest,
@@ -256,6 +261,12 @@ function parseMediaTimingReviewPreviewRequest(
};
}
function parseMediaTimingReviewWaveformRequest(
payload: unknown,
): MediaTimingReviewWaveformRequest | null {
return parseMediaTimingReviewPreviewRequest(payload);
}
function parseMediaTimingReviewResolveRequest(
payload: unknown,
): MediaTimingReviewResolveRequest | null {
@@ -344,6 +355,7 @@ export interface IpcDepsRuntimeOptions {
request: YoutubePickerResolveRequest,
) => Promise<YoutubePickerResolveResult>;
previewMediaTimingReview?: IpcServiceDeps['previewMediaTimingReview'];
getMediaTimingReviewWaveform?: IpcServiceDeps['getMediaTimingReviewWaveform'];
stopMediaTimingReviewPreview?: IpcServiceDeps['stopMediaTimingReviewPreview'];
resolveMediaTimingReview?: IpcServiceDeps['resolveMediaTimingReview'];
getAnkiConnectStatus: () => boolean;
@@ -440,6 +452,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
runSubsyncManual: options.runSubsyncManual,
onYoutubePickerResolve: options.onYoutubePickerResolve,
previewMediaTimingReview: options.previewMediaTimingReview,
getMediaTimingReviewWaveform: options.getMediaTimingReviewWaveform,
stopMediaTimingReviewPreview: options.stopMediaTimingReviewPreview,
resolveMediaTimingReview: options.resolveMediaTimingReview,
getAnkiConnectStatus: options.getAnkiConnectStatus,
@@ -579,6 +592,16 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return await deps.previewMediaTimingReview(request);
},
);
ipc.handle(
IPC_CHANNELS.request.mediaTimingReviewWaveform,
async (_event: unknown, payload: unknown) => {
const request = parseMediaTimingReviewWaveformRequest(payload);
if (!request || !deps.getMediaTimingReviewWaveform) {
return { ok: false, message: 'Timing waveform is unavailable.' };
}
return await deps.getMediaTimingReviewWaveform(request);
},
);
ipc.handle(
IPC_CHANNELS.request.mediaTimingReviewStopPreview,
async (_event: unknown, reviewId: unknown) => {
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildSpeechWaveformArgs,
computeWaveformPeaks,
generateSpeechWaveform,
} from './media-timing-waveform';
function pcm(samples: number[]): Buffer {
const result = Buffer.alloc(samples.length * 2);
samples.forEach((sample, index) => result.writeInt16LE(sample, index * 2));
return result;
}
test('speech waveform maps the selected FFmpeg stream and visible range', () => {
const args = buildSpeechWaveformArgs(
{
mediaPath: '/video/show.mkv',
startTime: 8,
endTime: 15,
audioStreamIndex: 3,
},
'center',
);
assert.deepEqual(args.slice(args.indexOf('-ss'), args.indexOf('-t') + 2), [
'-ss',
'8',
'-i',
'/video/show.mkv',
'-t',
'7',
]);
assert.deepEqual(args.slice(args.indexOf('-map'), args.indexOf('-map') + 2), ['-map', '0:3']);
assert.match(args[args.indexOf('-af') + 1] ?? '', /c0=FC/);
});
test('waveform peaks are normalized without flattening quieter sections', () => {
const peaks = computeWaveformPeaks(pcm([0, 1_000, -2_000, 4_000, -8_000, 16_000]), 3);
assert.equal(peaks.length, 3);
assert.ok((peaks[0] ?? 0) > 0);
assert.ok((peaks[0] ?? 0) < (peaks[1] ?? 0));
assert.ok((peaks[1] ?? 0) < (peaks[2] ?? 0));
assert.equal(peaks[2], 1);
});
test('speech waveform uses a mono downmix when the source has no center activity', async () => {
const calls: string[][] = [];
const peaks = await generateSpeechWaveform(
{ mediaPath: '/video/show.mkv', startTime: 0, endTime: 2 },
async (args) => {
calls.push(args);
return calls.length === 1 ? pcm([0, 0, 0, 0]) : pcm([0, 4_000, -8_000, 16_000]);
},
);
assert.equal(calls.length, 2);
assert.match(calls[1]?.[calls[1].indexOf('-af') + 1] ?? '', /channel_layouts=mono/);
assert.equal(Math.max(...peaks), 1);
});
test('speech waveform keeps an active center channel without doing a second decode', async () => {
let calls = 0;
await generateSpeechWaveform(
{ mediaPath: '/video/show.mkv', startTime: 0, endTime: 2 },
async () => {
calls += 1;
return pcm([0, 4_000, -8_000, 16_000]);
},
);
assert.equal(calls, 1);
});
+158
View File
@@ -0,0 +1,158 @@
import { spawn } from 'node:child_process';
const WAVEFORM_SAMPLE_RATE = 8_000;
const WAVEFORM_POINT_COUNT = 480;
const WAVEFORM_TIMEOUT_MS = 15_000;
const MAX_WAVEFORM_BYTES = 16 * 1024 * 1024;
const SPEECH_FILTER = 'highpass=f=120,lowpass=f=4000';
const CENTER_CHANNEL_FILTER = `pan=mono|c0=FC,${SPEECH_FILTER}`;
const DOWNMIX_FILTER = `aformat=channel_layouts=mono,${SPEECH_FILTER}`;
export interface SpeechWaveformOptions {
mediaPath: string;
startTime: number;
endTime: number;
audioStreamIndex?: number;
}
type RunFfmpeg = (args: string[]) => Promise<Buffer>;
export function buildSpeechWaveformArgs(
options: SpeechWaveformOptions,
mode: 'center' | 'downmix',
): string[] {
const duration = options.endTime - options.startTime;
const args = [
'-hide_banner',
'-nostdin',
'-loglevel',
'error',
'-ss',
String(options.startTime),
'-i',
options.mediaPath,
'-t',
String(duration),
];
if (
options.audioStreamIndex !== undefined &&
Number.isInteger(options.audioStreamIndex) &&
options.audioStreamIndex >= 0
) {
args.push('-map', `0:${options.audioStreamIndex}`);
}
args.push(
'-vn',
'-sn',
'-dn',
'-af',
mode === 'center' ? CENTER_CHANNEL_FILTER : DOWNMIX_FILTER,
'-ac',
'1',
'-ar',
String(WAVEFORM_SAMPLE_RATE),
'-f',
's16le',
'pipe:1',
);
return args;
}
function runFfmpeg(args: string[]): Promise<Buffer> {
return new Promise((resolve, reject) => {
const child = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
const chunks: Buffer[] = [];
let byteLength = 0;
let stderr = '';
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
child.kill('SIGKILL');
reject(new Error(`FFmpeg waveform analysis timed out after ${WAVEFORM_TIMEOUT_MS}ms`));
}, WAVEFORM_TIMEOUT_MS);
const settle = (callback: () => void): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
callback();
};
child.stdout.on('data', (chunk: Buffer) => {
if (settled) return;
byteLength += chunk.byteLength;
if (byteLength > MAX_WAVEFORM_BYTES) {
settle(() => {
child.kill('SIGKILL');
reject(new Error('The visible waveform range is too large to analyze.'));
});
return;
}
chunks.push(chunk);
});
child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk) => {
if (stderr.length < 4_000) stderr += String(chunk);
});
child.once('error', (error) => settle(() => reject(error)));
child.once('close', (code) => {
settle(() => {
if (code === 0) {
resolve(Buffer.concat(chunks, byteLength));
return;
}
reject(new Error(stderr.trim() || `FFmpeg exited with status ${code ?? 'unknown'}`));
});
});
});
}
export function computeWaveformPeaks(pcm: Buffer, pointCount = WAVEFORM_POINT_COUNT): number[] {
const sampleCount = Math.floor(pcm.byteLength / 2);
if (sampleCount === 0 || pointCount <= 0) return [];
const resolvedPointCount = Math.min(pointCount, sampleCount);
const peaks = Array.from({ length: resolvedPointCount }, () => 0);
for (let point = 0; point < resolvedPointCount; point += 1) {
const sampleStart = Math.floor((point * sampleCount) / resolvedPointCount);
const sampleEnd = Math.max(
sampleStart + 1,
Math.floor(((point + 1) * sampleCount) / resolvedPointCount),
);
let peak = 0;
for (let sample = sampleStart; sample < sampleEnd; sample += 1) {
peak = Math.max(peak, Math.abs(pcm.readInt16LE(sample * 2)) / 32_768);
}
peaks[point] = peak;
}
const sortedPeaks = [...peaks].sort((left, right) => left - right);
const referenceIndex = Math.min(sortedPeaks.length - 1, Math.floor(sortedPeaks.length * 0.95));
const referencePeak = Math.max(sortedPeaks[referenceIndex] ?? 0, 0.01);
return peaks.map(
(peak) => Math.round(Math.sqrt(Math.min(1, peak / referencePeak)) * 1_000) / 1_000,
);
}
function hasAudibleSamples(pcm: Buffer): boolean {
for (let offset = 0; offset + 1 < pcm.byteLength; offset += 2) {
if (Math.abs(pcm.readInt16LE(offset)) >= 164) return true;
}
return false;
}
export async function generateSpeechWaveform(
options: SpeechWaveformOptions,
execute: RunFfmpeg = runFfmpeg,
): Promise<number[]> {
try {
const centerPcm = await execute(buildSpeechWaveformArgs(options, 'center'));
if (hasAudibleSamples(centerPcm)) return computeWaveformPeaks(centerPcm);
} catch {
// Sources without a named center channel can reject the center-only filter.
}
const downmixPcm = await execute(buildSpeechWaveformArgs(options, 'downmix'));
return computeWaveformPeaks(downmixPcm);
}
+3
View File
@@ -466,6 +466,7 @@ import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
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 { openMediaTimingReviewModal } from './main/runtime/media-timing-review-open';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
@@ -2895,6 +2896,7 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
getMpvExecutablePath: () =>
configService.getConfig().mpv.executablePath || process.env.SUBMINER_MPV_PATH?.trim() || '',
createPreviewSession: () => new MediaTimingPreviewSession(),
generateWaveform: (options) => generateSpeechWaveform(options),
openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload),
showStatus: (message) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }),
@@ -5547,6 +5549,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
},
mainDeps: {
previewMediaTimingReview: (request) => mediaTimingReviewRuntime.previewRange(request),
getMediaTimingReviewWaveform: (request) => mediaTimingReviewRuntime.getWaveform(request),
stopMediaTimingReviewPreview: (reviewId) => mediaTimingReviewRuntime.stopPreview(reviewId),
resolveMediaTimingReview: (request) => mediaTimingReviewRuntime.resolveReview(request),
getMainWindow: () => overlayManager.getMainWindow(),
+2
View File
@@ -63,6 +63,7 @@ export interface MainIpcRuntimeServiceDepsParams {
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
previewMediaTimingReview?: IpcDepsRuntimeOptions['previewMediaTimingReview'];
getMediaTimingReviewWaveform?: IpcDepsRuntimeOptions['getMediaTimingReviewWaveform'];
stopMediaTimingReviewPreview?: IpcDepsRuntimeOptions['stopMediaTimingReviewPreview'];
resolveMediaTimingReview?: IpcDepsRuntimeOptions['resolveMediaTimingReview'];
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
@@ -261,6 +262,7 @@ export function createMainIpcRuntimeServiceDeps(
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
onYoutubePickerResolve: params.onYoutubePickerResolve,
previewMediaTimingReview: params.previewMediaTimingReview,
getMediaTimingReviewWaveform: params.getMediaTimingReviewWaveform,
stopMediaTimingReviewPreview: params.stopMediaTimingReviewPreview,
resolveMediaTimingReview: params.resolveMediaTimingReview,
openYomitanSettings: params.openYomitanSettings,
@@ -79,6 +79,7 @@ test('media timing review pauses playback, resolves exact timing, and restores p
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => undefined,
play: async (startTime, endTime) => {
@@ -125,6 +126,69 @@ test('media timing review pauses playback, resolves exact timing, and restores p
assert.deepEqual(previewCalls, [[9.5, 12.5]]);
});
test('media timing review analyzes the visible range on the selected audio stream', async () => {
const waveformCalls: Array<{
mediaPath: string;
startTime: number;
endTime: number;
audioStreamIndex?: number;
}> = [];
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
currentAudioStreamIndex: 4,
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
send: () => undefined,
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async (options) => {
waveformCalls.push(options);
return [0.1, 0.8, 0.2];
},
createPreviewSession: () => ({
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
const waveform = await runtime.getWaveform({
reviewId: payload.reviewId,
startTime: payload.timelineStartTime,
endTime: payload.timelineEndTime,
});
assert.deepEqual(waveform, { ok: true, peaks: [0.1, 0.8, 0.2] });
runtime.resolveReview({
reviewId: payload.reviewId,
decision: { action: 'use-original' },
});
return true;
},
showStatus: () => undefined,
});
await runtime.requestReview({
kind: 'sentence',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0.5,
maxMediaDuration: 30,
});
assert.deepEqual(waveformCalls, [
{
mediaPath: '/video/show.mkv',
startTime: 7.5,
endTime: 14.5,
audioStreamIndex: 4,
},
]);
});
test('media timing review does not resume playback when the prior state is unavailable', async () => {
const commands: Array<Array<string | number>> = [];
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
@@ -137,6 +201,7 @@ test('media timing review does not resume playback when the prior state is unava
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => '',
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => {
throw new Error('preview unavailable');
@@ -182,6 +247,7 @@ test('media timing review restores playback when setup fails after pausing', asy
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => {
throw new Error('preview setup failed');
},
@@ -217,6 +283,7 @@ test('disposing an open review settles it with original timing and restores play
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => undefined,
play: async () => undefined,
+49
View File
@@ -6,13 +6,17 @@ import type {
MediaTimingReviewPreviewRequest,
MediaTimingReviewRequest,
MediaTimingReviewResolveRequest,
MediaTimingReviewWaveformRequest,
MediaTimingReviewWaveformResult,
} from '../../types/anki';
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
const INITIAL_TIMELINE_MARGIN_SECONDS = 2;
interface ReviewMpvClient {
connected: boolean;
currentVideoPath: string;
currentAudioStreamIndex?: number | null;
requestProperty?: (name: string) => Promise<unknown>;
send: (payload: { command: Array<string | number> }) => void;
}
@@ -31,6 +35,8 @@ interface PreviewSession {
interface ActiveReview {
payload: MediaTimingReviewOpenPayload;
mediaPath: string;
audioStreamIndex?: number;
mpvClient: ReviewMpvClient;
restorePlayback: boolean;
preview: Promise<PreviewSession>;
@@ -42,6 +48,7 @@ export interface MediaTimingReviewRuntimeDeps {
getCurrentMediaPath: () => string | null;
getMpvExecutablePath: () => string;
createPreviewSession: () => PreviewSession;
generateWaveform: (options: SpeechWaveformOptions) => Promise<number[]>;
openModal: (payload: MediaTimingReviewOpenPayload) => Promise<boolean>;
showStatus: (message: string) => void;
}
@@ -152,6 +159,11 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
});
active = {
payload,
mediaPath,
...(mpvClient.currentAudioStreamIndex !== null &&
mpvClient.currentAudioStreamIndex !== undefined
? { audioStreamIndex: mpvClient.currentAudioStreamIndex }
: {}),
mpvClient,
restorePlayback: pendingPauseRestore === mpvClient,
preview,
@@ -222,6 +234,42 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
}
}
async function getWaveform(
request: MediaTimingReviewWaveformRequest,
): Promise<MediaTimingReviewWaveformResult> {
const current = active;
if (!current || request.reviewId !== current.payload.reviewId) {
return { ok: false, message: 'This timing review is no longer active.' };
}
if (
!Number.isFinite(request.startTime) ||
!Number.isFinite(request.endTime) ||
request.startTime < 0 ||
request.endTime <= request.startTime ||
(current.payload.mediaDuration !== undefined &&
request.endTime > current.payload.mediaDuration + 0.001)
) {
return { ok: false, message: 'The waveform range is invalid.' };
}
try {
const peaks = await deps.generateWaveform({
mediaPath: current.mediaPath,
startTime: request.startTime,
endTime: request.endTime,
...(current.audioStreamIndex !== undefined
? { audioStreamIndex: current.audioStreamIndex }
: {}),
});
if (active !== current || peaks.length < 2 || peaks.some((peak) => !Number.isFinite(peak))) {
return { ok: false, message: 'Timing waveform is unavailable.' };
}
return { ok: true, peaks };
} catch {
return { ok: false, message: 'Timing waveform is unavailable.' };
}
}
async function stopPreview(reviewId: string): Promise<MediaTimingReviewActionResult> {
const current = active;
if (!current || reviewId !== current.payload.reviewId) {
@@ -282,6 +330,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
return {
requestReview,
previewRange,
getWaveform,
stopPreview,
resolveReview,
dispose,
+3
View File
@@ -72,6 +72,7 @@ import type {
MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewRequest,
MediaTimingReviewResolveRequest,
MediaTimingReviewWaveformRequest,
} from './types';
import { IPC_CHANNELS } from './shared/ipc/contracts';
@@ -469,6 +470,8 @@ const electronAPI: ElectronAPI = {
onOpenMediaTimingReview: onOpenMediaTimingReviewEvent,
previewMediaTimingReview: (request: MediaTimingReviewPreviewRequest) =>
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewPreview, request),
getMediaTimingReviewWaveform: (request: MediaTimingReviewWaveformRequest) =>
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewWaveform, request),
stopMediaTimingReviewPreview: (reviewId: string) =>
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewStopPreview, reviewId),
resolveMediaTimingReview: (request: MediaTimingReviewResolveRequest) =>
+60 -26
View File
@@ -208,13 +208,11 @@
aria-labelledby="mediaTimingReviewTitle"
>
<div class="media-timing-review-header">
<div>
<div id="mediaTimingReviewKind" class="media-timing-review-eyebrow">
Sentence card
</div>
<div class="media-timing-review-heading">
<div id="mediaTimingReviewTitle" class="media-timing-review-title">
Review media timing
</div>
<div id="mediaTimingReviewKind" class="media-timing-review-kind">Sentence card</div>
</div>
<button
id="mediaTimingReviewCancel"
@@ -246,41 +244,70 @@
<div class="media-timing-review-timeline-shell">
<div class="media-timing-review-timeline-labels">
<span id="mediaTimingReviewTimelineStart">00:00</span>
<span>available media</span>
<span id="mediaTimingReviewWaveformLabel">speech-weighted waveform</span>
<span id="mediaTimingReviewTimelineEnd">00:00</span>
</div>
<div id="mediaTimingReviewSelectionTrack" class="media-timing-review-track">
<div class="media-timing-review-selected-range"></div>
<input
id="mediaTimingReviewStartRange"
class="media-timing-review-range media-timing-review-range-start"
type="range"
step="0.01"
<svg
class="media-timing-review-waveform"
viewBox="0 0 1000 100"
preserveAspectRatio="none"
aria-hidden="true"
>
<defs>
<linearGradient id="mediaTimingReviewWaveformFill" x1="0" y1="0" x2="0" y2="1">
<stop class="media-timing-review-waveform-edge-stop" offset="0%" />
<stop class="media-timing-review-waveform-core-stop" offset="50%" />
<stop class="media-timing-review-waveform-edge-stop" offset="100%" />
</linearGradient>
</defs>
<path id="mediaTimingReviewWaveformPath"></path>
</svg>
<div
class="media-timing-review-original-range"
title="Original subtitle timing"
aria-hidden="true"
></div>
<div
id="mediaTimingReviewSelectedRange"
class="media-timing-review-selected-range"
aria-hidden="true"
></div>
<div class="media-timing-review-playhead" aria-hidden="true"></div>
<div
id="mediaTimingReviewStartHandle"
class="media-timing-review-handle media-timing-review-handle-start"
role="slider"
tabindex="0"
aria-label="Clip start"
/>
<input
id="mediaTimingReviewEndRange"
class="media-timing-review-range media-timing-review-range-end"
type="range"
step="0.01"
aria-orientation="horizontal"
></div>
<div
id="mediaTimingReviewEndHandle"
class="media-timing-review-handle media-timing-review-handle-end"
role="slider"
tabindex="0"
aria-label="Clip end"
/>
aria-orientation="horizontal"
></div>
</div>
<div class="media-timing-review-expand-row">
<button
id="mediaTimingReviewShowEarlier"
class="media-timing-review-expand-button"
type="button"
aria-label="Reveal five more seconds before the timeline"
>
Show earlier
&minus;5s
</button>
<span>Drag either edge to set the exact clip.</span>
<span>Drag an edge to trim, or drag the middle to slide the clip.</span>
<button
id="mediaTimingReviewShowLater"
class="media-timing-review-expand-button"
type="button"
aria-label="Reveal five more seconds after the timeline"
>
Show later
+5s
</button>
</div>
</div>
@@ -293,14 +320,14 @@
type="button"
aria-label="Move start backward 100 milliseconds"
>
0.1s
&minus;0.1s
</button>
<button
id="mediaTimingReviewStartForward"
type="button"
aria-label="Move start forward 100 milliseconds"
>
+ 0.1s
+0.1s
</button>
</div>
<div class="media-timing-review-fine-control">
@@ -310,14 +337,14 @@
type="button"
aria-label="Move end backward 100 milliseconds"
>
0.1s
&minus;0.1s
</button>
<button
id="mediaTimingReviewEndForward"
type="button"
aria-label="Move end forward 100 milliseconds"
>
+ 0.1s
+0.1s
</button>
</div>
</div>
@@ -335,7 +362,8 @@
class="media-timing-review-play-button"
type="button"
>
Play selection
<span class="media-timing-review-play-glyph" aria-hidden="true"></span>
<span id="mediaTimingReviewPlayLabel">Play selection</span>
</button>
<button
id="mediaTimingReviewReset"
@@ -353,6 +381,12 @@
Use this timing
</button>
</div>
<div class="media-timing-review-hints" aria-hidden="true">
<span><kbd>Space</kbd> preview</span>
<span><kbd>&larr;</kbd><kbd>&rarr;</kbd> nudge focused edge</span>
<span><kbd>Enter</kbd> confirm</span>
<span><kbd>Esc</kbd> cancel</span>
</div>
</div>
<div id="mediaTimingReviewCancelStep" class="media-timing-review-cancel-step hidden">
@@ -1,11 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildMediaTimingWaveformPath,
constrainMediaTimingSelection,
createMediaTimingPreviewRequestGuard,
formatMediaTimingTimestamp,
mediaTimingTimeFromPointer,
slideMediaTimingSelection,
} from './media-timing-review';
test('waveform path mirrors normalized peaks around its center line', () => {
const path = buildMediaTimingWaveformPath([0, 0.5, 1]);
assert.match(path, /^M 0\.00 50\.00 L 500\.00 28\.00 L 1000\.00 6\.00/);
assert.match(path, /1000\.00 94\.00 L 500\.00 72\.00 L 0\.00 50\.00 Z$/);
assert.equal(buildMediaTimingWaveformPath([1]), '');
});
test('formatMediaTimingTimestamp renders stable review readouts', () => {
assert.equal(formatMediaTimingTimestamp(65.4321), '01:05.432');
assert.equal(formatMediaTimingTimestamp(65.4321, false), '01:05');
@@ -41,6 +52,27 @@ test('selection constraints preserve the handle that did not move', () => {
);
});
test('pointer positions map onto the visible timeline and clamp past its edges', () => {
const track = { trackLeft: 100, trackWidth: 400, timelineStart: 10, timelineEnd: 20 };
assert.equal(mediaTimingTimeFromPointer({ clientX: 100, ...track }), 10);
assert.equal(mediaTimingTimeFromPointer({ clientX: 300, ...track }), 15);
assert.equal(mediaTimingTimeFromPointer({ clientX: -40, ...track }), 10);
assert.equal(mediaTimingTimeFromPointer({ clientX: 900, ...track }), 20);
assert.equal(mediaTimingTimeFromPointer({ ...track, clientX: 300, trackWidth: 0 }), 10);
});
test('sliding keeps the clip length and stops at the timeline and media bounds', () => {
const timeline = { span: 2, timelineStart: 4, timelineEnd: 12, mediaEnd: 10 };
assert.deepEqual(slideMediaTimingSelection({ nextStart: 6, ...timeline }), { start: 6, end: 8 });
assert.deepEqual(slideMediaTimingSelection({ nextStart: 1, ...timeline }), { start: 4, end: 6 });
assert.deepEqual(slideMediaTimingSelection({ nextStart: 99, ...timeline }), {
start: 8,
end: 10,
});
});
test('preview request guard blocks overlap and invalidates stale responses', () => {
const guard = createMediaTimingPreviewRequestGuard();
const first = guard.begin();
+280 -16
View File
@@ -3,6 +3,7 @@ import type { ModalStateReader, RendererContext } from '../context';
const MINIMUM_CLIP_SECONDS = 0.1;
const FINE_ADJUST_SECONDS = 0.1;
const COARSE_ADJUST_SECONDS = 0.5;
const TIMELINE_EXPANSION_SECONDS = 5;
function clamp(value: number, minimum: number, maximum: number): number {
@@ -48,6 +49,19 @@ export function createMediaTimingPreviewRequestGuard() {
};
}
export function buildMediaTimingWaveformPath(peaks: number[]): string {
if (peaks.length < 2) return '';
const points = peaks.map((peak, index) => ({
x: (index / (peaks.length - 1)) * 1_000,
amplitude: clamp(Number.isFinite(peak) ? peak : 0, 0, 1) * 44,
}));
const upper = points.map(({ x, amplitude }) => `${x.toFixed(2)} ${(50 - amplitude).toFixed(2)}`);
const lower = [...points]
.reverse()
.map(({ x, amplitude }) => `${x.toFixed(2)} ${(50 + amplitude).toFixed(2)}`);
return `M ${upper.join(' L ')} L ${lower.join(' L ')} Z`;
}
export function constrainMediaTimingSelection(options: {
nextStart: number;
nextEnd: number;
@@ -74,6 +88,33 @@ export function constrainMediaTimingSelection(options: {
};
}
/** Maps a pointer position over the timeline track back onto a media timestamp. */
export function mediaTimingTimeFromPointer(options: {
clientX: number;
trackLeft: number;
trackWidth: number;
timelineStart: number;
timelineEnd: number;
}): number {
if (options.trackWidth <= 0) return options.timelineStart;
const ratio = clamp((options.clientX - options.trackLeft) / options.trackWidth, 0, 1);
return options.timelineStart + ratio * (options.timelineEnd - options.timelineStart);
}
/** Slides the selection without resizing it, keeping the whole clip inside the visible timeline. */
export function slideMediaTimingSelection(options: {
nextStart: number;
span: number;
timelineStart: number;
timelineEnd: number;
mediaEnd: number;
}): { start: number; end: number } {
const latestEnd = Math.min(options.timelineEnd, options.mediaEnd);
const maxStart = Math.max(options.timelineStart, latestEnd - options.span);
const start = clamp(options.nextStart, options.timelineStart, maxStart);
return { start, end: start + options.span };
}
export function createMediaTimingReviewModal(
ctx: RendererContext,
options: {
@@ -89,6 +130,15 @@ export function createMediaTimingReviewModal(
let resolveInFlight = false;
let previewPlaying = false;
let previewTimer: ReturnType<typeof setTimeout> | null = null;
let waveformTimer: ReturnType<typeof setTimeout> | null = null;
let waveformSequence = 0;
let drag: {
edge: 'start' | 'end' | 'both';
pointerId: number;
trackLeft: number;
trackWidth: number;
grabOffset: number;
} | null = null;
const previewRequest = createMediaTimingPreviewRequestGuard();
function setStatus(message: string, isError = false): void {
@@ -101,28 +151,49 @@ export function createMediaTimingReviewModal(
previewTimer = null;
}
/** Drives the play button label plus the playhead sweep that mirrors the hidden audio player. */
function setPreviewPlaying(playing: boolean): void {
previewPlaying = playing;
ctx.dom.mediaTimingReviewPlay.textContent = playing ? 'Stop preview' : 'Play selection';
ctx.dom.mediaTimingReviewPlayLabel.textContent = playing ? 'Stop preview' : 'Play selection';
ctx.dom.mediaTimingReviewPlay.classList.toggle('is-playing', playing);
clearPreviewTimer();
if (playing) {
previewTimer = setTimeout(() => stopPreview(), (selectionEnd - selectionStart) * 1000);
}
const track = ctx.dom.mediaTimingReviewSelectionTrack;
track.classList.remove('is-previewing');
if (!playing) return;
const clipSeconds = Math.max(MINIMUM_CLIP_SECONDS, selectionEnd - selectionStart);
track.style.setProperty('--playhead-duration', `${clipSeconds}s`);
void track.offsetWidth;
track.classList.add('is-previewing');
previewTimer = setTimeout(() => stopPreview(), clipSeconds * 1000);
}
/** Callers that need to report a failure set their own status after stopping the preview. */
function stopPreview(): void {
previewRequest.invalidate();
setPreviewPlaying(false);
setStatus('');
if (payload) {
void window.electronAPI.stopMediaTimingReviewPreview(payload.reviewId).catch(() => {});
}
}
function renderHandle(handle: HTMLDivElement, value: number): void {
handle.setAttribute('aria-valuemin', timelineStart.toFixed(3));
handle.setAttribute('aria-valuemax', timelineEnd.toFixed(3));
handle.setAttribute('aria-valuenow', value.toFixed(3));
handle.setAttribute('aria-valuetext', formatMediaTimingTimestamp(value));
}
function renderSelection(): void {
const span = Math.max(MINIMUM_CLIP_SECONDS, timelineEnd - timelineStart);
const startPercent = ((selectionStart - timelineStart) / span) * 100;
const endPercent = ((selectionEnd - timelineStart) / span) * 100;
const originalStartPercent = payload
? ((payload.originalStartTime - timelineStart) / span) * 100
: 0;
const originalEndPercent = payload
? ((payload.originalEndTime - timelineStart) / span) * 100
: 0;
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
'--selection-start',
`${clamp(startPercent, 0, 100)}%`,
@@ -131,12 +202,16 @@ export function createMediaTimingReviewModal(
'--selection-end',
`${clamp(endPercent, 0, 100)}%`,
);
ctx.dom.mediaTimingReviewStartRange.min = String(timelineStart);
ctx.dom.mediaTimingReviewStartRange.max = String(timelineEnd);
ctx.dom.mediaTimingReviewStartRange.value = String(selectionStart);
ctx.dom.mediaTimingReviewEndRange.min = String(timelineStart);
ctx.dom.mediaTimingReviewEndRange.max = String(timelineEnd);
ctx.dom.mediaTimingReviewEndRange.value = String(selectionEnd);
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
'--original-start',
`${clamp(originalStartPercent, 0, 100)}%`,
);
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
'--original-end',
`${clamp(originalEndPercent, 0, 100)}%`,
);
renderHandle(ctx.dom.mediaTimingReviewStartHandle, selectionStart);
renderHandle(ctx.dom.mediaTimingReviewEndHandle, selectionEnd);
ctx.dom.mediaTimingReviewStartValue.textContent = formatMediaTimingTimestamp(selectionStart);
ctx.dom.mediaTimingReviewEndValue.textContent = formatMediaTimingTimestamp(selectionEnd);
ctx.dom.mediaTimingReviewDuration.textContent = `${(selectionEnd - selectionStart).toFixed(2)}s`;
@@ -153,6 +228,70 @@ export function createMediaTimingReviewModal(
payload?.mediaDuration !== undefined && timelineEnd >= payload.mediaDuration;
}
function setWaveformState(state: 'loading' | 'ready' | 'unavailable'): void {
ctx.dom.mediaTimingReviewSelectionTrack.classList.toggle('is-loading', state === 'loading');
ctx.dom.mediaTimingReviewSelectionTrack.classList.toggle(
'is-waveform-unavailable',
state === 'unavailable',
);
ctx.dom.mediaTimingReviewWaveformLabel.textContent =
state === 'loading'
? 'isolating dialogue...'
: state === 'ready'
? 'speech-weighted waveform'
: 'waveform unavailable';
}
async function loadWaveform(): Promise<void> {
if (!payload || !ctx.state.mediaTimingReviewModalOpen) return;
waveformSequence += 1;
const sequence = waveformSequence;
const reviewId = payload.reviewId;
const startTime = timelineStart;
const endTime = timelineEnd;
setWaveformState('loading');
ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', '');
try {
const result = await window.electronAPI.getMediaTimingReviewWaveform({
reviewId,
startTime,
endTime,
});
if (
sequence !== waveformSequence ||
payload?.reviewId !== reviewId ||
timelineStart !== startTime ||
timelineEnd !== endTime
) {
return;
}
const path = result.ok ? buildMediaTimingWaveformPath(result.peaks ?? []) : '';
if (!path) {
setWaveformState('unavailable');
return;
}
ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', path);
setWaveformState('ready');
} catch {
if (sequence === waveformSequence && payload?.reviewId === reviewId) {
setWaveformState('unavailable');
}
}
}
function queueWaveformLoad(delayMs = 0): void {
if (waveformTimer !== null) clearTimeout(waveformTimer);
waveformSequence += 1;
if (payload && ctx.state.mediaTimingReviewModalOpen) {
ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', '');
setWaveformState('loading');
}
waveformTimer = setTimeout(() => {
waveformTimer = null;
void loadWaveform();
}, delayMs);
}
function updateSelection(nextStart: number, nextEnd: number): void {
if (!payload) return;
const mediaEnd = payload.mediaDuration ?? Number.POSITIVE_INFINITY;
@@ -172,6 +311,116 @@ export function createMediaTimingReviewModal(
renderSelection();
}
/** Shifts the whole clip without changing its length. */
function moveSelection(nextStart: number): void {
if (!payload) return;
const slid = slideMediaTimingSelection({
nextStart,
span: selectionEnd - selectionStart,
timelineStart,
timelineEnd,
mediaEnd: payload.mediaDuration ?? Number.POSITIVE_INFINITY,
});
updateSelection(slid.start, slid.end);
}
function setEdge(edge: 'start' | 'end', value: number): void {
if (edge === 'start') updateSelection(value, selectionEnd);
else updateSelection(selectionStart, value);
}
function applyDrag(clientX: number): void {
if (!drag) return;
const pointerTime = mediaTimingTimeFromPointer({
clientX,
trackLeft: drag.trackLeft,
trackWidth: drag.trackWidth,
timelineStart,
timelineEnd,
});
const time = pointerTime - drag.grabOffset;
if (drag.edge === 'both') moveSelection(time);
else setEdge(drag.edge, time);
}
/**
* Grabbing a handle drags that edge, grabbing the highlighted clip slides the whole selection,
* and pressing anywhere else snaps the nearest edge to that point and keeps dragging it.
*/
function beginDrag(event: PointerEvent): void {
if (!payload || resolveInFlight || drag !== null || event.button !== 0) return;
const track = ctx.dom.mediaTimingReviewSelectionTrack;
const rect = track.getBoundingClientRect();
const pointerTime = mediaTimingTimeFromPointer({
clientX: event.clientX,
trackLeft: rect.left,
trackWidth: rect.width,
timelineStart,
timelineEnd,
});
const target = event.target;
let edge: 'start' | 'end' | 'both';
let grabOffset: number;
if (target === ctx.dom.mediaTimingReviewStartHandle) {
edge = 'start';
grabOffset = pointerTime - selectionStart;
} else if (target === ctx.dom.mediaTimingReviewEndHandle) {
edge = 'end';
grabOffset = pointerTime - selectionEnd;
} else if (target === ctx.dom.mediaTimingReviewSelectedRange) {
edge = 'both';
grabOffset = pointerTime - selectionStart;
} else {
edge =
Math.abs(pointerTime - selectionStart) <= Math.abs(pointerTime - selectionEnd)
? 'start'
: 'end';
grabOffset = 0;
}
event.preventDefault();
drag = {
edge,
pointerId: event.pointerId,
trackLeft: rect.left,
trackWidth: rect.width,
grabOffset,
};
track.setPointerCapture(event.pointerId);
ctx.dom.mediaTimingReviewModal.classList.add(edge === 'both' ? 'is-sliding' : 'is-scrubbing');
if (edge !== 'both') {
const handle =
edge === 'start'
? ctx.dom.mediaTimingReviewStartHandle
: ctx.dom.mediaTimingReviewEndHandle;
handle.focus();
if (grabOffset === 0) applyDrag(event.clientX);
}
}
function endDrag(event: PointerEvent): void {
if (!drag || drag.pointerId !== event.pointerId) return;
const track = ctx.dom.mediaTimingReviewSelectionTrack;
if (track.hasPointerCapture(event.pointerId)) track.releasePointerCapture(event.pointerId);
drag = null;
ctx.dom.mediaTimingReviewModal.classList.remove('is-scrubbing', 'is-sliding');
}
function cancelDrag(): void {
drag = null;
ctx.dom.mediaTimingReviewModal.classList.remove('is-scrubbing', 'is-sliding');
}
function handleEdgeKeydown(event: KeyboardEvent, edge: 'start' | 'end'): void {
const step = event.shiftKey ? COARSE_ADJUST_SECONDS : FINE_ADJUST_SECONDS;
const current = edge === 'start' ? selectionStart : selectionEnd;
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') setEdge(edge, current - step);
else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') setEdge(edge, current + step);
else if (event.key === 'Home') setEdge(edge, timelineStart);
else if (event.key === 'End') setEdge(edge, timelineEnd);
else return;
event.preventDefault();
}
function showEditor(): void {
ctx.dom.mediaTimingReviewCancelStep.classList.add('hidden');
ctx.dom.mediaTimingReviewEditor.classList.remove('hidden');
@@ -195,7 +444,11 @@ export function createMediaTimingReviewModal(
function closeResolvedReview(): void {
if (!ctx.state.mediaTimingReviewModalOpen) return;
cancelDrag();
clearPreviewTimer();
if (waveformTimer !== null) clearTimeout(waveformTimer);
waveformTimer = null;
waveformSequence += 1;
previewPlaying = false;
ctx.state.mediaTimingReviewModalOpen = false;
ctx.dom.mediaTimingReviewModal.classList.add('hidden');
@@ -285,6 +538,7 @@ export function createMediaTimingReviewModal(
function openMediaTimingReviewModal(nextPayload: MediaTimingReviewOpenPayload): void {
previewRequest.invalidate();
cancelDrag();
payload = nextPayload;
selectionStart = nextPayload.selectionStartTime;
selectionEnd = nextPayload.selectionEndTime;
@@ -298,6 +552,7 @@ export function createMediaTimingReviewModal(
: nextPayload.kind === 'audio'
? '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";
@@ -311,7 +566,8 @@ export function createMediaTimingReviewModal(
ctx.dom.mediaTimingReviewModal.classList.remove('hidden');
ctx.dom.mediaTimingReviewModal.setAttribute('aria-hidden', 'false');
window.electronAPI.notifyOverlayModalOpened('media-timing-review');
ctx.dom.mediaTimingReviewPlay.focus();
ctx.dom.mediaTimingReviewStartHandle.focus();
queueWaveformLoad();
}
function expandTimeline(direction: 'earlier' | 'later'): void {
@@ -325,6 +581,7 @@ export function createMediaTimingReviewModal(
);
}
renderSelection();
queueWaveformLoad(120);
}
function handleMediaTimingReviewKeydown(event: KeyboardEvent): boolean {
@@ -357,12 +614,19 @@ export function createMediaTimingReviewModal(
}
function wireDomEvents(): void {
ctx.dom.mediaTimingReviewStartRange.addEventListener('input', () => {
updateSelection(Number(ctx.dom.mediaTimingReviewStartRange.value), selectionEnd);
});
ctx.dom.mediaTimingReviewEndRange.addEventListener('input', () => {
updateSelection(selectionStart, Number(ctx.dom.mediaTimingReviewEndRange.value));
const track = ctx.dom.mediaTimingReviewSelectionTrack;
track.addEventListener('pointerdown', beginDrag);
track.addEventListener('pointermove', (event) => {
if (drag?.pointerId === event.pointerId) applyDrag(event.clientX);
});
track.addEventListener('pointerup', endDrag);
track.addEventListener('pointercancel', endDrag);
ctx.dom.mediaTimingReviewStartHandle.addEventListener('keydown', (event) =>
handleEdgeKeydown(event, 'start'),
);
ctx.dom.mediaTimingReviewEndHandle.addEventListener('keydown', (event) =>
handleEdgeKeydown(event, 'end'),
);
ctx.dom.mediaTimingReviewShowEarlier.addEventListener('click', () => expandTimeline('earlier'));
ctx.dom.mediaTimingReviewShowLater.addEventListener('click', () => expandTimeline('later'));
ctx.dom.mediaTimingReviewStartBack.addEventListener('click', () =>
+330 -90
View File
@@ -1325,22 +1325,34 @@ body:focus-visible,
/* Media timing review uses the Catppuccin Macchiato palette without opacity-shifted colors. */
.media-timing-review-modal {
background: rgba(24, 25, 38, 0.74);
backdrop-filter: blur(8px);
background: rgba(24, 25, 38, 0.76);
backdrop-filter: blur(10px);
}
/* Pointer capture keeps events on the track, so the cursor is forced from the modal root. */
.media-timing-review-modal.is-scrubbing,
.media-timing-review-modal.is-scrubbing * {
cursor: ew-resize;
}
.media-timing-review-modal.is-sliding,
.media-timing-review-modal.is-sliding * {
cursor: grabbing;
}
.media-timing-review-content {
width: min(760px, calc(100vw - 32px));
max-height: min(760px, calc(100vh - 32px));
width: min(820px, calc(100vw - 32px));
max-height: min(780px, calc(100vh - 32px));
overflow: auto;
gap: 0;
padding: 0;
border: 1px solid var(--ctp-surface1);
border-radius: 18px;
background: var(--ctp-base);
color: var(--ctp-text);
box-shadow:
0 26px 80px rgba(24, 25, 38, 0.74),
0 0 0 1px rgba(183, 189, 248, 0.08) inset;
0 30px 90px rgba(24, 25, 38, 0.8),
0 0 0 1px rgba(183, 189, 248, 0.07) inset;
animation: media-timing-review-enter 180ms cubic-bezier(0.2, 0.9, 0.25, 1);
}
@@ -1356,40 +1368,66 @@ body:focus-visible,
}
.media-timing-review-header {
position: sticky;
z-index: 2;
top: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 22px 24px 18px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--ctp-surface0);
background: var(--ctp-mantle);
background:
linear-gradient(90deg, var(--ctp-teal), var(--ctp-blue) 45%, var(--ctp-mauve)) top / 100% 2px
no-repeat,
var(--ctp-mantle);
}
.media-timing-review-eyebrow {
margin-bottom: 3px;
color: var(--ctp-teal);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
.media-timing-review-heading {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.media-timing-review-title {
color: var(--ctp-text);
font-size: 22px;
font-size: 21px;
font-weight: 750;
letter-spacing: -0.025em;
}
.media-timing-review-kind {
--kind-accent: var(--ctp-teal);
padding: 3px 9px;
border: 1px solid color-mix(in srgb, var(--kind-accent) 42%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--kind-accent) 13%, transparent);
color: var(--kind-accent);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.media-timing-review-kind[data-kind='word'] {
--kind-accent: var(--ctp-mauve);
}
.media-timing-review-kind[data-kind='audio'] {
--kind-accent: var(--ctp-sky);
}
.media-timing-review-editor {
padding: 20px 24px 24px;
padding: 18px 24px 22px;
}
.media-timing-review-text {
max-height: 108px;
overflow: auto;
margin: 0 0 18px;
padding: 13px 16px;
margin: 0 0 16px;
padding: 12px 16px;
border-left: 3px solid var(--ctp-mauve);
border-radius: 0 10px 10px 0;
background: var(--ctp-mantle);
@@ -1402,9 +1440,9 @@ body:focus-visible,
.media-timing-review-readout {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-columns: 1fr auto 1fr;
overflow: hidden;
margin-bottom: 14px;
margin-bottom: 12px;
border: 1px solid var(--ctp-surface1);
border-radius: 12px;
background: var(--ctp-mantle);
@@ -1414,8 +1452,8 @@ body:focus-visible,
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
padding: 11px 14px;
gap: 3px;
padding: 10px 14px;
}
.media-timing-review-readout > div + div {
@@ -1423,10 +1461,10 @@ body:focus-visible,
}
.media-timing-review-readout span {
color: var(--ctp-overlay2);
color: var(--ctp-overlay1);
font-size: 10px;
font-weight: 750;
letter-spacing: 0.09em;
letter-spacing: 0.1em;
text-transform: uppercase;
}
@@ -1439,12 +1477,22 @@ body:focus-visible,
.media-timing-review-readout .media-timing-review-duration {
align-items: center;
min-width: 128px;
background: var(--ctp-surface0);
text-align: center;
}
.media-timing-review-readout > div:last-child {
align-items: flex-end;
text-align: right;
}
.media-timing-review-readout .media-timing-review-duration strong {
color: var(--ctp-teal);
}
.media-timing-review-timeline-shell {
padding: 14px 16px 12px;
padding: 12px 14px 10px;
border: 1px solid var(--ctp-surface1);
border-radius: 14px;
background: var(--ctp-mantle);
@@ -1471,79 +1519,202 @@ body:focus-visible,
.media-timing-review-track {
--selection-start: 20%;
--selection-end: 80%;
--original-start: 25%;
--original-end: 75%;
--playhead-duration: 1s;
position: relative;
height: 44px;
height: 96px;
margin: 8px 0 9px;
overflow: hidden;
border: 1px solid var(--ctp-surface2);
border-radius: 8px;
border-radius: 10px;
background:
linear-gradient(var(--ctp-surface1), var(--ctp-surface1)) center / 100% 1px no-repeat,
repeating-linear-gradient(
90deg,
transparent 0,
transparent calc(10% - 1px),
var(--ctp-surface1) calc(10% - 1px),
var(--ctp-surface1) 10%
color-mix(in srgb, var(--ctp-surface1) 65%, transparent) calc(10% - 1px),
color-mix(in srgb, var(--ctp-surface1) 65%, transparent) 10%
),
var(--ctp-surface0);
linear-gradient(180deg, var(--ctp-crust), var(--ctp-mantle));
cursor: ew-resize;
touch-action: none;
user-select: none;
}
.media-timing-review-waveform {
position: absolute;
z-index: 1;
inset: 6px 0;
width: 100%;
height: calc(100% - 12px);
pointer-events: none;
transition: opacity 160ms ease;
}
#mediaTimingReviewWaveformPath {
fill: url('#mediaTimingReviewWaveformFill');
}
.media-timing-review-waveform-edge-stop {
stop-color: var(--ctp-sky);
}
.media-timing-review-waveform-core-stop {
stop-color: var(--ctp-blue);
}
/* Sits above the dimming scrim so the subtitle bounds stay readable outside the selection. */
.media-timing-review-original-range {
position: absolute;
z-index: 4;
top: 3px;
bottom: 3px;
left: var(--original-start);
right: calc(100% - var(--original-end));
min-width: 1px;
border-inline: 1px dashed var(--ctp-peach);
pointer-events: none;
}
.media-timing-review-original-range::before {
position: absolute;
top: 0;
left: 0;
padding: 2px 4px;
border-radius: 0 0 5px;
background: color-mix(in srgb, var(--ctp-crust) 72%, transparent);
content: 'subtitle';
color: var(--ctp-peach);
font-size: 8px;
font-weight: 760;
letter-spacing: 0.08em;
line-height: 1;
text-transform: uppercase;
}
.media-timing-review-track.is-loading::after {
position: absolute;
z-index: 2;
inset: 0;
content: '';
background: linear-gradient(
105deg,
transparent 20%,
rgba(138, 173, 244, 0.16) 44%,
rgba(183, 189, 248, 0.22) 50%,
transparent 76%
);
transform: translateX(-100%);
animation: media-timing-waveform-loading 1.1s ease-in-out infinite;
pointer-events: none;
}
.media-timing-review-track.is-waveform-unavailable .media-timing-review-waveform {
opacity: 0;
}
@keyframes media-timing-waveform-loading {
to {
transform: translateX(100%);
}
}
/* The oversized shadow spread dims everything outside the clip; the track clips it. */
.media-timing-review-selected-range {
position: absolute;
top: 7px;
bottom: 7px;
left: var(--selection-start);
right: calc(100% - var(--selection-end));
border: 1px solid var(--ctp-teal);
border-radius: 6px;
background: var(--ctp-sapphire);
box-shadow: 0 0 18px rgba(125, 196, 228, 0.24);
pointer-events: none;
z-index: 3;
inset: 0 calc(100% - var(--selection-end)) 0 var(--selection-start);
border-inline: 1px solid color-mix(in srgb, var(--ctp-teal) 65%, transparent);
background: color-mix(in srgb, var(--ctp-teal) 9%, transparent);
box-shadow: 0 0 0 2000px color-mix(in srgb, var(--ctp-crust) 62%, transparent);
cursor: grab;
}
.media-timing-review-range {
.media-timing-review-playhead {
position: absolute;
inset: 0;
width: 100%;
height: 44px;
margin: 0;
appearance: none;
background: transparent;
z-index: 5;
top: 0;
bottom: 0;
left: var(--selection-start);
width: 2px;
background: var(--ctp-yellow);
box-shadow: 0 0 12px color-mix(in srgb, var(--ctp-yellow) 75%, transparent);
opacity: 0;
pointer-events: none;
}
.media-timing-review-range::-webkit-slider-runnable-track {
height: 100%;
background: transparent;
.media-timing-review-track.is-previewing .media-timing-review-playhead {
opacity: 1;
animation: media-timing-review-playhead var(--playhead-duration) linear forwards;
}
.media-timing-review-range::-webkit-slider-thumb {
width: 15px;
height: 34px;
margin-top: 4px;
appearance: none;
border: 2px solid var(--ctp-base);
border-radius: 5px;
background: var(--ctp-text);
box-shadow: 0 2px 7px var(--ctp-crust);
@keyframes media-timing-review-playhead {
from {
left: var(--selection-start);
}
to {
left: var(--selection-end);
}
}
.media-timing-review-handle {
position: absolute;
z-index: 6;
top: 0;
bottom: 0;
width: 14px;
border: 1px solid var(--ctp-teal);
background: linear-gradient(
180deg,
var(--ctp-teal),
color-mix(in srgb, var(--ctp-teal) 74%, var(--ctp-crust))
);
cursor: ew-resize;
pointer-events: auto;
touch-action: none;
transition: box-shadow 130ms ease;
}
.media-timing-review-range::-moz-range-track {
height: 100%;
background: transparent;
/* Widens the grab area past the visible bracket without moving the clip edge. */
.media-timing-review-handle::before {
position: absolute;
inset: 0 -7px;
content: '';
}
.media-timing-review-range::-moz-range-thumb {
width: 15px;
height: 34px;
border: 2px solid var(--ctp-base);
border-radius: 5px;
background: var(--ctp-text);
box-shadow: 0 2px 7px var(--ctp-crust);
cursor: ew-resize;
pointer-events: auto;
.media-timing-review-handle::after {
position: absolute;
top: 50%;
left: 50%;
width: 6px;
height: 18px;
content: '';
border-inline: 1px solid color-mix(in srgb, var(--ctp-crust) 55%, transparent);
transform: translate(-50%, -50%);
}
.media-timing-review-handle-start {
left: var(--selection-start);
border-radius: 5px 0 0 5px;
}
.media-timing-review-handle-end {
left: var(--selection-end);
border-radius: 0 5px 5px 0;
transform: translateX(-100%);
}
.media-timing-review-handle:hover {
box-shadow: 0 0 16px color-mix(in srgb, var(--ctp-teal) 60%, transparent);
}
.media-timing-review-handle:focus-visible {
outline: none;
box-shadow:
inset 0 0 0 2px var(--ctp-yellow),
0 0 16px color-mix(in srgb, var(--ctp-yellow) 55%, transparent);
}
.media-timing-review-expand-row span {
@@ -1558,7 +1729,7 @@ body:focus-visible,
.media-timing-review-confirm-button,
.media-timing-review-fine-control button {
border: 1px solid var(--ctp-surface2);
border-radius: 8px;
border-radius: 9px;
background: var(--ctp-surface0);
color: var(--ctp-text);
font: inherit;
@@ -1572,8 +1743,9 @@ body:focus-visible,
}
.media-timing-review-expand-button {
padding: 5px 9px;
padding: 5px 10px;
color: var(--ctp-sky);
font-variant-numeric: tabular-nums;
}
.media-timing-review-expand-button:disabled,
@@ -1591,8 +1763,7 @@ body:focus-visible,
transform: translateY(1px);
}
.media-timing-review-content button:focus-visible,
.media-timing-review-range:focus-visible::-webkit-slider-thumb {
.media-timing-review-content button:focus-visible {
outline: 2px solid var(--ctp-yellow);
outline-offset: 2px;
}
@@ -1601,34 +1772,37 @@ body:focus-visible,
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 12px;
margin-top: 10px;
}
.media-timing-review-fine-control {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 7px;
padding: 9px 10px;
gap: 6px;
padding: 8px 10px;
border: 1px solid var(--ctp-surface0);
border-radius: 10px;
background: var(--ctp-mantle);
color: var(--ctp-subtext1);
}
.media-timing-review-fine-control span {
font-size: 12px;
font-size: 11px;
font-weight: 750;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.media-timing-review-fine-control button {
min-width: 62px;
min-width: 58px;
padding: 5px 8px;
font-variant-numeric: tabular-nums;
}
.media-timing-review-status {
min-height: 21px;
padding-top: 7px;
padding-top: 8px;
color: var(--ctp-teal);
font-size: 11px;
font-weight: 620;
@@ -1647,7 +1821,7 @@ body:focus-visible,
.media-timing-review-footer {
justify-content: space-between;
padding-top: 5px;
padding-top: 4px;
}
.media-timing-review-play-button,
@@ -1655,20 +1829,38 @@ body:focus-visible,
.media-timing-review-quiet-button,
.media-timing-review-original-button,
.media-timing-review-discard-button {
padding: 9px 14px;
padding: 9px 15px;
}
.media-timing-review-play-button {
display: inline-flex;
align-items: center;
gap: 9px;
border-color: var(--ctp-teal);
background: var(--ctp-teal);
color: var(--ctp-crust);
}
.media-timing-review-play-glyph {
width: 0;
height: 0;
border-block: 5px solid transparent;
border-left: 9px solid currentcolor;
}
.media-timing-review-play-button.is-playing {
border-color: var(--ctp-yellow);
background: var(--ctp-yellow);
}
.media-timing-review-play-button.is-playing .media-timing-review-play-glyph {
width: 9px;
height: 10px;
border: 0;
border-radius: 2px;
background: currentcolor;
}
.media-timing-review-confirm-button {
border-color: var(--ctp-blue);
background: var(--ctp-blue);
@@ -1686,20 +1878,48 @@ body:focus-visible,
color: var(--ctp-crust);
}
.media-timing-review-hints {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
padding-top: 14px;
color: var(--ctp-overlay1);
font-size: 10px;
font-weight: 650;
letter-spacing: 0.04em;
}
.media-timing-review-hints kbd {
display: inline-block;
min-width: 15px;
margin-right: 3px;
padding: 1px 5px;
border: 1px solid var(--ctp-surface1);
border-bottom-width: 2px;
border-radius: 5px;
background: var(--ctp-mantle);
color: var(--ctp-subtext0);
font-family: inherit;
font-size: 9px;
font-weight: 750;
text-align: center;
}
.media-timing-review-cancel-step {
min-height: 330px;
padding: 48px 38px 38px;
min-height: 300px;
padding: 44px 38px 34px;
text-align: center;
}
.media-timing-review-cancel-mark {
display: grid;
width: 54px;
height: 54px;
width: 52px;
height: 52px;
margin: 0 auto 16px;
place-items: center;
border: 2px solid var(--ctp-yellow);
border-radius: 50%;
background: color-mix(in srgb, var(--ctp-yellow) 12%, transparent);
color: var(--ctp-yellow);
font-size: 26px;
font-weight: 760;
@@ -1707,13 +1927,13 @@ body:focus-visible,
.media-timing-review-cancel-step h2 {
color: var(--ctp-text);
font-size: 22px;
font-size: 21px;
font-weight: 760;
}
.media-timing-review-cancel-step p {
max-width: 520px;
margin: 8px auto 26px;
margin: 8px auto 24px;
color: var(--ctp-subtext0);
font-size: 13px;
line-height: 1.5;
@@ -1726,6 +1946,17 @@ body:focus-visible,
gap: 10px;
}
@media (prefers-reduced-motion: reduce) {
.media-timing-review-content {
animation: none;
}
.media-timing-review-track.is-loading::after,
.media-timing-review-track.is-previewing .media-timing-review-playhead {
animation: none;
}
}
@media (max-width: 640px) {
.media-timing-review-content {
width: calc(100vw - 18px);
@@ -1738,6 +1969,15 @@ body:focus-visible,
padding-left: 15px;
}
.media-timing-review-readout {
grid-template-columns: 1fr;
}
.media-timing-review-readout > div + div {
border-top: 1px solid var(--ctp-surface0);
border-left: 0;
}
.media-timing-review-fine-grid {
grid-template-columns: 1fr;
}
+21 -7
View File
@@ -53,8 +53,11 @@ export type RendererDom = {
mediaTimingReviewTimelineStart: HTMLElement;
mediaTimingReviewTimelineEnd: HTMLElement;
mediaTimingReviewSelectionTrack: HTMLDivElement;
mediaTimingReviewStartRange: HTMLInputElement;
mediaTimingReviewEndRange: HTMLInputElement;
mediaTimingReviewWaveformLabel: HTMLElement;
mediaTimingReviewWaveformPath: SVGPathElement;
mediaTimingReviewSelectedRange: HTMLDivElement;
mediaTimingReviewStartHandle: HTMLDivElement;
mediaTimingReviewEndHandle: HTMLDivElement;
mediaTimingReviewShowEarlier: HTMLButtonElement;
mediaTimingReviewShowLater: HTMLButtonElement;
mediaTimingReviewStartBack: HTMLButtonElement;
@@ -62,6 +65,7 @@ export type RendererDom = {
mediaTimingReviewEndBack: HTMLButtonElement;
mediaTimingReviewEndForward: HTMLButtonElement;
mediaTimingReviewPlay: HTMLButtonElement;
mediaTimingReviewPlayLabel: HTMLElement;
mediaTimingReviewReset: HTMLButtonElement;
mediaTimingReviewCancel: HTMLButtonElement;
mediaTimingReviewConfirm: HTMLButtonElement;
@@ -174,8 +178,8 @@ export type RendererDom = {
playlistBrowserClose: HTMLButtonElement;
};
function getRequiredElement<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
function getRequiredElement<T extends Element>(id: string): T {
const element = document.querySelector(`#${id}`);
if (!element) {
throw new Error(`Missing required DOM element #${id}`);
}
@@ -246,10 +250,19 @@ export function resolveRendererDom(): RendererDom {
mediaTimingReviewSelectionTrack: getRequiredElement<HTMLDivElement>(
'mediaTimingReviewSelectionTrack',
),
mediaTimingReviewStartRange: getRequiredElement<HTMLInputElement>(
'mediaTimingReviewStartRange',
mediaTimingReviewWaveformLabel: getRequiredElement<HTMLElement>(
'mediaTimingReviewWaveformLabel',
),
mediaTimingReviewEndRange: getRequiredElement<HTMLInputElement>('mediaTimingReviewEndRange'),
mediaTimingReviewWaveformPath: getRequiredElement<SVGPathElement>(
'mediaTimingReviewWaveformPath',
),
mediaTimingReviewSelectedRange: getRequiredElement<HTMLDivElement>(
'mediaTimingReviewSelectedRange',
),
mediaTimingReviewStartHandle: getRequiredElement<HTMLDivElement>(
'mediaTimingReviewStartHandle',
),
mediaTimingReviewEndHandle: getRequiredElement<HTMLDivElement>('mediaTimingReviewEndHandle'),
mediaTimingReviewShowEarlier: getRequiredElement<HTMLButtonElement>(
'mediaTimingReviewShowEarlier',
),
@@ -263,6 +276,7 @@ export function resolveRendererDom(): RendererDom {
'mediaTimingReviewEndForward',
),
mediaTimingReviewPlay: getRequiredElement<HTMLButtonElement>('mediaTimingReviewPlay'),
mediaTimingReviewPlayLabel: getRequiredElement<HTMLElement>('mediaTimingReviewPlayLabel'),
mediaTimingReviewReset: getRequiredElement<HTMLButtonElement>('mediaTimingReviewReset'),
mediaTimingReviewCancel: getRequiredElement<HTMLButtonElement>('mediaTimingReviewCancel'),
mediaTimingReviewConfirm: getRequiredElement<HTMLButtonElement>('mediaTimingReviewConfirm'),
+1
View File
@@ -127,6 +127,7 @@ export const IPC_CHANNELS = {
syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file',
getChangelogSnapshot: 'changelog:get-snapshot',
mediaTimingReviewPreview: 'media-timing-review:preview',
mediaTimingReviewWaveform: 'media-timing-review:waveform',
mediaTimingReviewStopPreview: 'media-timing-review:stop-preview',
mediaTimingReviewResolve: 'media-timing-review:resolve',
},
+10
View File
@@ -49,6 +49,16 @@ export interface MediaTimingReviewPreviewRequest {
endTime: number;
}
export interface MediaTimingReviewWaveformRequest {
reviewId: string;
startTime: number;
endTime: number;
}
export interface MediaTimingReviewWaveformResult extends MediaTimingReviewActionResult {
peaks?: number[];
}
export interface MediaTimingReviewResolveRequest {
reviewId: string;
decision: MediaTimingReviewDecision;
+5
View File
@@ -7,6 +7,8 @@ import type {
MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewRequest,
MediaTimingReviewResolveRequest,
MediaTimingReviewWaveformRequest,
MediaTimingReviewWaveformResult,
} from './anki';
import type { ChangelogSnapshot } from './changelog';
import type { ResolvedConfig, ShortcutsConfig } from './config';
@@ -524,6 +526,9 @@ export interface ElectronAPI {
previewMediaTimingReview: (
request: MediaTimingReviewPreviewRequest,
) => Promise<MediaTimingReviewActionResult>;
getMediaTimingReviewWaveform: (
request: MediaTimingReviewWaveformRequest,
) => Promise<MediaTimingReviewWaveformResult>;
stopMediaTimingReviewPreview: (reviewId: string) => Promise<MediaTimingReviewActionResult>;
resolveMediaTimingReview: (
request: MediaTimingReviewResolveRequest,