mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-17 12:18:31 -07:00
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:
@@ -1,4 +1,4 @@
|
|||||||
type: added
|
type: added
|
||||||
area: mining
|
area: mining
|
||||||
|
|
||||||
- Added optional pre-generation timing review for word, sentence, and audio cards with draggable clip bounds, audio preview, exact screenshot and AVIF timing, and explicit cancellation choices.
|
- Added optional pre-generation timing review for word, sentence, and audio cards with a speech-weighted waveform you can drag to trim or slide the clip, keyboard nudging, audio preview with a sweeping playhead, exact screenshot and AVIF timing, and explicit cancellation choices.
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMine
|
|||||||
|
|
||||||
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
||||||
|
|
||||||
Set `media.reviewTiming` to `true` to pause playback and review each word, sentence, or audio card before its media is generated. The review opens with the subtitle range plus configured audio padding, provides draggable start and end handles, 100 ms adjustments, audio preview, and controls to reveal another five seconds before or after the visible timeline. The confirmed range is exact: SubMiner does not apply audio padding a second time. Static screenshots use its midpoint, and animated AVIF clips use the full confirmed range.
|
Set `media.reviewTiming` to `true` to pause playback and review each word, sentence, or audio card before its media is generated. The review opens with the subtitle range plus configured audio padding. Drag either edge of the clip to trim it, drag the middle to slide it without changing its length, or press anywhere else on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys, by 100 ms alone or 500 ms with Shift, and the 100 ms buttons do the same. Space previews the selection with a playhead that sweeps the clip, Enter confirms, and Escape cancels; buttons reveal another five seconds before or after the visible timeline. A speech-weighted waveform and markers for the original subtitle timing help locate sentence boundaries. SubMiner uses a center channel when one carries dialogue, then falls back to a speech-band mono mix. Waveform analysis failure leaves the timing controls available. The confirmed range is exact: SubMiner does not apply audio padding a second time. Static screenshots use its midpoint, and animated AVIF clips use the full confirmed range.
|
||||||
|
|
||||||
Canceling the review lets you keep editing, finish with the original timing, or discard the card. Discard deletes an existing Yomitan or audio card and skips creation for a direct sentence card. Clipboard updates and stats-dashboard mining do not open timing review. Audio preview failure does not block confirmation or card creation. The option is disabled by default and hot-reloads.
|
Canceling the review lets you keep editing, finish with the original timing, or discard the card. Discard deletes an existing Yomitan or audio card and skips creation for a direct sentence card. Clipboard updates and stats-dashboard mining do not open timing review. Audio preview failure does not block confirmation or card creation. The option is disabled by default and hot-reloads.
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import type {
|
|||||||
MediaTimingReviewActionResult,
|
MediaTimingReviewActionResult,
|
||||||
MediaTimingReviewPreviewRequest,
|
MediaTimingReviewPreviewRequest,
|
||||||
MediaTimingReviewResolveRequest,
|
MediaTimingReviewResolveRequest,
|
||||||
|
MediaTimingReviewWaveformRequest,
|
||||||
|
MediaTimingReviewWaveformResult,
|
||||||
} from '../../types/anki';
|
} from '../../types/anki';
|
||||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||||
import {
|
import {
|
||||||
@@ -106,6 +108,9 @@ export interface IpcServiceDeps {
|
|||||||
previewMediaTimingReview?: (
|
previewMediaTimingReview?: (
|
||||||
request: MediaTimingReviewPreviewRequest,
|
request: MediaTimingReviewPreviewRequest,
|
||||||
) => Promise<MediaTimingReviewActionResult>;
|
) => Promise<MediaTimingReviewActionResult>;
|
||||||
|
getMediaTimingReviewWaveform?: (
|
||||||
|
request: MediaTimingReviewWaveformRequest,
|
||||||
|
) => Promise<MediaTimingReviewWaveformResult>;
|
||||||
stopMediaTimingReviewPreview?: (reviewId: string) => Promise<MediaTimingReviewActionResult>;
|
stopMediaTimingReviewPreview?: (reviewId: string) => Promise<MediaTimingReviewActionResult>;
|
||||||
resolveMediaTimingReview?: (
|
resolveMediaTimingReview?: (
|
||||||
request: MediaTimingReviewResolveRequest,
|
request: MediaTimingReviewResolveRequest,
|
||||||
@@ -255,6 +260,12 @@ function parseMediaTimingReviewPreviewRequest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMediaTimingReviewWaveformRequest(
|
||||||
|
payload: unknown,
|
||||||
|
): MediaTimingReviewWaveformRequest | null {
|
||||||
|
return parseMediaTimingReviewPreviewRequest(payload);
|
||||||
|
}
|
||||||
|
|
||||||
function parseMediaTimingReviewResolveRequest(
|
function parseMediaTimingReviewResolveRequest(
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
): MediaTimingReviewResolveRequest | null {
|
): MediaTimingReviewResolveRequest | null {
|
||||||
@@ -343,6 +354,7 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
request: YoutubePickerResolveRequest,
|
request: YoutubePickerResolveRequest,
|
||||||
) => Promise<YoutubePickerResolveResult>;
|
) => Promise<YoutubePickerResolveResult>;
|
||||||
previewMediaTimingReview?: IpcServiceDeps['previewMediaTimingReview'];
|
previewMediaTimingReview?: IpcServiceDeps['previewMediaTimingReview'];
|
||||||
|
getMediaTimingReviewWaveform?: IpcServiceDeps['getMediaTimingReviewWaveform'];
|
||||||
stopMediaTimingReviewPreview?: IpcServiceDeps['stopMediaTimingReviewPreview'];
|
stopMediaTimingReviewPreview?: IpcServiceDeps['stopMediaTimingReviewPreview'];
|
||||||
resolveMediaTimingReview?: IpcServiceDeps['resolveMediaTimingReview'];
|
resolveMediaTimingReview?: IpcServiceDeps['resolveMediaTimingReview'];
|
||||||
getAnkiConnectStatus: () => boolean;
|
getAnkiConnectStatus: () => boolean;
|
||||||
@@ -439,6 +451,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
runSubsyncManual: options.runSubsyncManual,
|
runSubsyncManual: options.runSubsyncManual,
|
||||||
onYoutubePickerResolve: options.onYoutubePickerResolve,
|
onYoutubePickerResolve: options.onYoutubePickerResolve,
|
||||||
previewMediaTimingReview: options.previewMediaTimingReview,
|
previewMediaTimingReview: options.previewMediaTimingReview,
|
||||||
|
getMediaTimingReviewWaveform: options.getMediaTimingReviewWaveform,
|
||||||
stopMediaTimingReviewPreview: options.stopMediaTimingReviewPreview,
|
stopMediaTimingReviewPreview: options.stopMediaTimingReviewPreview,
|
||||||
resolveMediaTimingReview: options.resolveMediaTimingReview,
|
resolveMediaTimingReview: options.resolveMediaTimingReview,
|
||||||
getAnkiConnectStatus: options.getAnkiConnectStatus,
|
getAnkiConnectStatus: options.getAnkiConnectStatus,
|
||||||
@@ -572,6 +585,16 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
return await deps.previewMediaTimingReview(request);
|
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.handle(
|
||||||
IPC_CHANNELS.request.mediaTimingReviewStopPreview,
|
IPC_CHANNELS.request.mediaTimingReviewStopPreview,
|
||||||
async (_event: unknown, reviewId: unknown) => {
|
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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -463,6 +463,7 @@ import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
|||||||
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
||||||
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
|
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
|
||||||
import { MediaTimingPreviewSession } from './core/services/media-timing-preview';
|
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 { createMediaTimingReviewRuntime } from './main/runtime/media-timing-review';
|
||||||
import { openMediaTimingReviewModal } from './main/runtime/media-timing-review-open';
|
import { openMediaTimingReviewModal } from './main/runtime/media-timing-review-open';
|
||||||
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
||||||
@@ -2807,6 +2808,7 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
|||||||
getMpvExecutablePath: () =>
|
getMpvExecutablePath: () =>
|
||||||
configService.getConfig().mpv.executablePath || process.env.SUBMINER_MPV_PATH?.trim() || '',
|
configService.getConfig().mpv.executablePath || process.env.SUBMINER_MPV_PATH?.trim() || '',
|
||||||
createPreviewSession: () => new MediaTimingPreviewSession(),
|
createPreviewSession: () => new MediaTimingPreviewSession(),
|
||||||
|
generateWaveform: (options) => generateSpeechWaveform(options),
|
||||||
openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload),
|
openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload),
|
||||||
showStatus: (message) =>
|
showStatus: (message) =>
|
||||||
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }),
|
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }),
|
||||||
@@ -5440,6 +5442,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
},
|
},
|
||||||
mainDeps: {
|
mainDeps: {
|
||||||
previewMediaTimingReview: (request) => mediaTimingReviewRuntime.previewRange(request),
|
previewMediaTimingReview: (request) => mediaTimingReviewRuntime.previewRange(request),
|
||||||
|
getMediaTimingReviewWaveform: (request) => mediaTimingReviewRuntime.getWaveform(request),
|
||||||
stopMediaTimingReviewPreview: (reviewId) => mediaTimingReviewRuntime.stopPreview(reviewId),
|
stopMediaTimingReviewPreview: (reviewId) => mediaTimingReviewRuntime.stopPreview(reviewId),
|
||||||
resolveMediaTimingReview: (request) => mediaTimingReviewRuntime.resolveReview(request),
|
resolveMediaTimingReview: (request) => mediaTimingReviewRuntime.resolveReview(request),
|
||||||
getMainWindow: () => overlayManager.getMainWindow(),
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
|
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
|
||||||
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
||||||
previewMediaTimingReview?: IpcDepsRuntimeOptions['previewMediaTimingReview'];
|
previewMediaTimingReview?: IpcDepsRuntimeOptions['previewMediaTimingReview'];
|
||||||
|
getMediaTimingReviewWaveform?: IpcDepsRuntimeOptions['getMediaTimingReviewWaveform'];
|
||||||
stopMediaTimingReviewPreview?: IpcDepsRuntimeOptions['stopMediaTimingReviewPreview'];
|
stopMediaTimingReviewPreview?: IpcDepsRuntimeOptions['stopMediaTimingReviewPreview'];
|
||||||
resolveMediaTimingReview?: IpcDepsRuntimeOptions['resolveMediaTimingReview'];
|
resolveMediaTimingReview?: IpcDepsRuntimeOptions['resolveMediaTimingReview'];
|
||||||
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
||||||
@@ -260,6 +261,7 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
|
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
|
||||||
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
||||||
previewMediaTimingReview: params.previewMediaTimingReview,
|
previewMediaTimingReview: params.previewMediaTimingReview,
|
||||||
|
getMediaTimingReviewWaveform: params.getMediaTimingReviewWaveform,
|
||||||
stopMediaTimingReviewPreview: params.stopMediaTimingReviewPreview,
|
stopMediaTimingReviewPreview: params.stopMediaTimingReviewPreview,
|
||||||
resolveMediaTimingReview: params.resolveMediaTimingReview,
|
resolveMediaTimingReview: params.resolveMediaTimingReview,
|
||||||
openYomitanSettings: params.openYomitanSettings,
|
openYomitanSettings: params.openYomitanSettings,
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ test('media timing review pauses playback, resolves exact timing, and restores p
|
|||||||
}),
|
}),
|
||||||
getCurrentMediaPath: () => '/video/show.mkv',
|
getCurrentMediaPath: () => '/video/show.mkv',
|
||||||
getMpvExecutablePath: () => 'mpv',
|
getMpvExecutablePath: () => 'mpv',
|
||||||
|
generateWaveform: async () => [],
|
||||||
createPreviewSession: () => ({
|
createPreviewSession: () => ({
|
||||||
start: async () => undefined,
|
start: async () => undefined,
|
||||||
play: async (startTime, endTime) => {
|
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]]);
|
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 () => {
|
test('media timing review does not resume playback when the prior state is unavailable', async () => {
|
||||||
const commands: Array<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
|
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',
|
getCurrentMediaPath: () => '/video/show.mkv',
|
||||||
getMpvExecutablePath: () => '',
|
getMpvExecutablePath: () => '',
|
||||||
|
generateWaveform: async () => [],
|
||||||
createPreviewSession: () => ({
|
createPreviewSession: () => ({
|
||||||
start: async () => {
|
start: async () => {
|
||||||
throw new Error('preview unavailable');
|
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',
|
getCurrentMediaPath: () => '/video/show.mkv',
|
||||||
getMpvExecutablePath: () => 'mpv',
|
getMpvExecutablePath: () => 'mpv',
|
||||||
|
generateWaveform: async () => [],
|
||||||
createPreviewSession: () => {
|
createPreviewSession: () => {
|
||||||
throw new Error('preview setup failed');
|
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',
|
getCurrentMediaPath: () => '/video/show.mkv',
|
||||||
getMpvExecutablePath: () => 'mpv',
|
getMpvExecutablePath: () => 'mpv',
|
||||||
|
generateWaveform: async () => [],
|
||||||
createPreviewSession: () => ({
|
createPreviewSession: () => ({
|
||||||
start: async () => undefined,
|
start: async () => undefined,
|
||||||
play: async () => undefined,
|
play: async () => undefined,
|
||||||
|
|||||||
@@ -6,13 +6,17 @@ import type {
|
|||||||
MediaTimingReviewPreviewRequest,
|
MediaTimingReviewPreviewRequest,
|
||||||
MediaTimingReviewRequest,
|
MediaTimingReviewRequest,
|
||||||
MediaTimingReviewResolveRequest,
|
MediaTimingReviewResolveRequest,
|
||||||
|
MediaTimingReviewWaveformRequest,
|
||||||
|
MediaTimingReviewWaveformResult,
|
||||||
} from '../../types/anki';
|
} from '../../types/anki';
|
||||||
|
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
|
||||||
|
|
||||||
const INITIAL_TIMELINE_MARGIN_SECONDS = 2;
|
const INITIAL_TIMELINE_MARGIN_SECONDS = 2;
|
||||||
|
|
||||||
interface ReviewMpvClient {
|
interface ReviewMpvClient {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
currentVideoPath: string;
|
currentVideoPath: string;
|
||||||
|
currentAudioStreamIndex?: number | null;
|
||||||
requestProperty?: (name: string) => Promise<unknown>;
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
send: (payload: { command: Array<string | number> }) => void;
|
send: (payload: { command: Array<string | number> }) => void;
|
||||||
}
|
}
|
||||||
@@ -31,6 +35,8 @@ interface PreviewSession {
|
|||||||
|
|
||||||
interface ActiveReview {
|
interface ActiveReview {
|
||||||
payload: MediaTimingReviewOpenPayload;
|
payload: MediaTimingReviewOpenPayload;
|
||||||
|
mediaPath: string;
|
||||||
|
audioStreamIndex?: number;
|
||||||
mpvClient: ReviewMpvClient;
|
mpvClient: ReviewMpvClient;
|
||||||
restorePlayback: boolean;
|
restorePlayback: boolean;
|
||||||
preview: Promise<PreviewSession>;
|
preview: Promise<PreviewSession>;
|
||||||
@@ -42,6 +48,7 @@ export interface MediaTimingReviewRuntimeDeps {
|
|||||||
getCurrentMediaPath: () => string | null;
|
getCurrentMediaPath: () => string | null;
|
||||||
getMpvExecutablePath: () => string;
|
getMpvExecutablePath: () => string;
|
||||||
createPreviewSession: () => PreviewSession;
|
createPreviewSession: () => PreviewSession;
|
||||||
|
generateWaveform: (options: SpeechWaveformOptions) => Promise<number[]>;
|
||||||
openModal: (payload: MediaTimingReviewOpenPayload) => Promise<boolean>;
|
openModal: (payload: MediaTimingReviewOpenPayload) => Promise<boolean>;
|
||||||
showStatus: (message: string) => void;
|
showStatus: (message: string) => void;
|
||||||
}
|
}
|
||||||
@@ -152,6 +159,11 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
|||||||
});
|
});
|
||||||
active = {
|
active = {
|
||||||
payload,
|
payload,
|
||||||
|
mediaPath,
|
||||||
|
...(mpvClient.currentAudioStreamIndex !== null &&
|
||||||
|
mpvClient.currentAudioStreamIndex !== undefined
|
||||||
|
? { audioStreamIndex: mpvClient.currentAudioStreamIndex }
|
||||||
|
: {}),
|
||||||
mpvClient,
|
mpvClient,
|
||||||
restorePlayback: pendingPauseRestore === mpvClient,
|
restorePlayback: pendingPauseRestore === mpvClient,
|
||||||
preview,
|
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> {
|
async function stopPreview(reviewId: string): Promise<MediaTimingReviewActionResult> {
|
||||||
const current = active;
|
const current = active;
|
||||||
if (!current || reviewId !== current.payload.reviewId) {
|
if (!current || reviewId !== current.payload.reviewId) {
|
||||||
@@ -282,6 +330,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
|||||||
return {
|
return {
|
||||||
requestReview,
|
requestReview,
|
||||||
previewRange,
|
previewRange,
|
||||||
|
getWaveform,
|
||||||
stopPreview,
|
stopPreview,
|
||||||
resolveReview,
|
resolveReview,
|
||||||
dispose,
|
dispose,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import type {
|
|||||||
MediaTimingReviewOpenPayload,
|
MediaTimingReviewOpenPayload,
|
||||||
MediaTimingReviewPreviewRequest,
|
MediaTimingReviewPreviewRequest,
|
||||||
MediaTimingReviewResolveRequest,
|
MediaTimingReviewResolveRequest,
|
||||||
|
MediaTimingReviewWaveformRequest,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||||
|
|
||||||
@@ -469,6 +470,8 @@ const electronAPI: ElectronAPI = {
|
|||||||
onOpenMediaTimingReview: onOpenMediaTimingReviewEvent,
|
onOpenMediaTimingReview: onOpenMediaTimingReviewEvent,
|
||||||
previewMediaTimingReview: (request: MediaTimingReviewPreviewRequest) =>
|
previewMediaTimingReview: (request: MediaTimingReviewPreviewRequest) =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewPreview, request),
|
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewPreview, request),
|
||||||
|
getMediaTimingReviewWaveform: (request: MediaTimingReviewWaveformRequest) =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewWaveform, request),
|
||||||
stopMediaTimingReviewPreview: (reviewId: string) =>
|
stopMediaTimingReviewPreview: (reviewId: string) =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewStopPreview, reviewId),
|
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewStopPreview, reviewId),
|
||||||
resolveMediaTimingReview: (request: MediaTimingReviewResolveRequest) =>
|
resolveMediaTimingReview: (request: MediaTimingReviewResolveRequest) =>
|
||||||
|
|||||||
+60
-26
@@ -208,13 +208,11 @@
|
|||||||
aria-labelledby="mediaTimingReviewTitle"
|
aria-labelledby="mediaTimingReviewTitle"
|
||||||
>
|
>
|
||||||
<div class="media-timing-review-header">
|
<div class="media-timing-review-header">
|
||||||
<div>
|
<div class="media-timing-review-heading">
|
||||||
<div id="mediaTimingReviewKind" class="media-timing-review-eyebrow">
|
|
||||||
Sentence card
|
|
||||||
</div>
|
|
||||||
<div id="mediaTimingReviewTitle" class="media-timing-review-title">
|
<div id="mediaTimingReviewTitle" class="media-timing-review-title">
|
||||||
Review media timing
|
Review media timing
|
||||||
</div>
|
</div>
|
||||||
|
<div id="mediaTimingReviewKind" class="media-timing-review-kind">Sentence card</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
id="mediaTimingReviewCancel"
|
id="mediaTimingReviewCancel"
|
||||||
@@ -246,41 +244,70 @@
|
|||||||
<div class="media-timing-review-timeline-shell">
|
<div class="media-timing-review-timeline-shell">
|
||||||
<div class="media-timing-review-timeline-labels">
|
<div class="media-timing-review-timeline-labels">
|
||||||
<span id="mediaTimingReviewTimelineStart">00:00</span>
|
<span id="mediaTimingReviewTimelineStart">00:00</span>
|
||||||
<span>available media</span>
|
<span id="mediaTimingReviewWaveformLabel">speech-weighted waveform</span>
|
||||||
<span id="mediaTimingReviewTimelineEnd">00:00</span>
|
<span id="mediaTimingReviewTimelineEnd">00:00</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="mediaTimingReviewSelectionTrack" class="media-timing-review-track">
|
<div id="mediaTimingReviewSelectionTrack" class="media-timing-review-track">
|
||||||
<div class="media-timing-review-selected-range"></div>
|
<svg
|
||||||
<input
|
class="media-timing-review-waveform"
|
||||||
id="mediaTimingReviewStartRange"
|
viewBox="0 0 1000 100"
|
||||||
class="media-timing-review-range media-timing-review-range-start"
|
preserveAspectRatio="none"
|
||||||
type="range"
|
aria-hidden="true"
|
||||||
step="0.01"
|
>
|
||||||
|
<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"
|
aria-label="Clip start"
|
||||||
/>
|
aria-orientation="horizontal"
|
||||||
<input
|
></div>
|
||||||
id="mediaTimingReviewEndRange"
|
<div
|
||||||
class="media-timing-review-range media-timing-review-range-end"
|
id="mediaTimingReviewEndHandle"
|
||||||
type="range"
|
class="media-timing-review-handle media-timing-review-handle-end"
|
||||||
step="0.01"
|
role="slider"
|
||||||
|
tabindex="0"
|
||||||
aria-label="Clip end"
|
aria-label="Clip end"
|
||||||
/>
|
aria-orientation="horizontal"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="media-timing-review-expand-row">
|
<div class="media-timing-review-expand-row">
|
||||||
<button
|
<button
|
||||||
id="mediaTimingReviewShowEarlier"
|
id="mediaTimingReviewShowEarlier"
|
||||||
class="media-timing-review-expand-button"
|
class="media-timing-review-expand-button"
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Reveal five more seconds before the timeline"
|
||||||
>
|
>
|
||||||
Show earlier
|
−5s
|
||||||
</button>
|
</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
|
<button
|
||||||
id="mediaTimingReviewShowLater"
|
id="mediaTimingReviewShowLater"
|
||||||
class="media-timing-review-expand-button"
|
class="media-timing-review-expand-button"
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Reveal five more seconds after the timeline"
|
||||||
>
|
>
|
||||||
Show later
|
+5s
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,14 +320,14 @@
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="Move start backward 100 milliseconds"
|
aria-label="Move start backward 100 milliseconds"
|
||||||
>
|
>
|
||||||
− 0.1s
|
−0.1s
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
id="mediaTimingReviewStartForward"
|
id="mediaTimingReviewStartForward"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Move start forward 100 milliseconds"
|
aria-label="Move start forward 100 milliseconds"
|
||||||
>
|
>
|
||||||
+ 0.1s
|
+0.1s
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="media-timing-review-fine-control">
|
<div class="media-timing-review-fine-control">
|
||||||
@@ -310,14 +337,14 @@
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="Move end backward 100 milliseconds"
|
aria-label="Move end backward 100 milliseconds"
|
||||||
>
|
>
|
||||||
− 0.1s
|
−0.1s
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
id="mediaTimingReviewEndForward"
|
id="mediaTimingReviewEndForward"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Move end forward 100 milliseconds"
|
aria-label="Move end forward 100 milliseconds"
|
||||||
>
|
>
|
||||||
+ 0.1s
|
+0.1s
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,7 +362,8 @@
|
|||||||
class="media-timing-review-play-button"
|
class="media-timing-review-play-button"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Play selection
|
<span class="media-timing-review-play-glyph" aria-hidden="true"></span>
|
||||||
|
<span id="mediaTimingReviewPlayLabel">Play selection</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
id="mediaTimingReviewReset"
|
id="mediaTimingReviewReset"
|
||||||
@@ -353,6 +381,12 @@
|
|||||||
Use this timing
|
Use this timing
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<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>Enter</kbd> confirm</span>
|
||||||
|
<span><kbd>Esc</kbd> cancel</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="mediaTimingReviewCancelStep" class="media-timing-review-cancel-step hidden">
|
<div id="mediaTimingReviewCancelStep" class="media-timing-review-cancel-step hidden">
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
|
buildMediaTimingWaveformPath,
|
||||||
constrainMediaTimingSelection,
|
constrainMediaTimingSelection,
|
||||||
createMediaTimingPreviewRequestGuard,
|
createMediaTimingPreviewRequestGuard,
|
||||||
formatMediaTimingTimestamp,
|
formatMediaTimingTimestamp,
|
||||||
|
mediaTimingTimeFromPointer,
|
||||||
|
slideMediaTimingSelection,
|
||||||
} from './media-timing-review';
|
} 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', () => {
|
test('formatMediaTimingTimestamp renders stable review readouts', () => {
|
||||||
assert.equal(formatMediaTimingTimestamp(65.4321), '01:05.432');
|
assert.equal(formatMediaTimingTimestamp(65.4321), '01:05.432');
|
||||||
assert.equal(formatMediaTimingTimestamp(65.4321, false), '01:05');
|
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', () => {
|
test('preview request guard blocks overlap and invalidates stale responses', () => {
|
||||||
const guard = createMediaTimingPreviewRequestGuard();
|
const guard = createMediaTimingPreviewRequestGuard();
|
||||||
const first = guard.begin();
|
const first = guard.begin();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ModalStateReader, RendererContext } from '../context';
|
|||||||
|
|
||||||
const MINIMUM_CLIP_SECONDS = 0.1;
|
const MINIMUM_CLIP_SECONDS = 0.1;
|
||||||
const FINE_ADJUST_SECONDS = 0.1;
|
const FINE_ADJUST_SECONDS = 0.1;
|
||||||
|
const COARSE_ADJUST_SECONDS = 0.5;
|
||||||
const TIMELINE_EXPANSION_SECONDS = 5;
|
const TIMELINE_EXPANSION_SECONDS = 5;
|
||||||
|
|
||||||
function clamp(value: number, minimum: number, maximum: number): number {
|
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: {
|
export function constrainMediaTimingSelection(options: {
|
||||||
nextStart: number;
|
nextStart: number;
|
||||||
nextEnd: 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(
|
export function createMediaTimingReviewModal(
|
||||||
ctx: RendererContext,
|
ctx: RendererContext,
|
||||||
options: {
|
options: {
|
||||||
@@ -89,6 +130,15 @@ export function createMediaTimingReviewModal(
|
|||||||
let resolveInFlight = false;
|
let resolveInFlight = false;
|
||||||
let previewPlaying = false;
|
let previewPlaying = false;
|
||||||
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
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();
|
const previewRequest = createMediaTimingPreviewRequestGuard();
|
||||||
|
|
||||||
function setStatus(message: string, isError = false): void {
|
function setStatus(message: string, isError = false): void {
|
||||||
@@ -101,28 +151,49 @@ export function createMediaTimingReviewModal(
|
|||||||
previewTimer = null;
|
previewTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drives the play button label plus the playhead sweep that mirrors the hidden audio player. */
|
||||||
function setPreviewPlaying(playing: boolean): void {
|
function setPreviewPlaying(playing: boolean): void {
|
||||||
previewPlaying = playing;
|
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);
|
ctx.dom.mediaTimingReviewPlay.classList.toggle('is-playing', playing);
|
||||||
clearPreviewTimer();
|
clearPreviewTimer();
|
||||||
if (playing) {
|
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
||||||
previewTimer = setTimeout(() => stopPreview(), (selectionEnd - selectionStart) * 1000);
|
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 {
|
function stopPreview(): void {
|
||||||
previewRequest.invalidate();
|
previewRequest.invalidate();
|
||||||
setPreviewPlaying(false);
|
setPreviewPlaying(false);
|
||||||
|
setStatus('');
|
||||||
if (payload) {
|
if (payload) {
|
||||||
void window.electronAPI.stopMediaTimingReviewPreview(payload.reviewId).catch(() => {});
|
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 {
|
function renderSelection(): void {
|
||||||
const span = Math.max(MINIMUM_CLIP_SECONDS, timelineEnd - timelineStart);
|
const span = Math.max(MINIMUM_CLIP_SECONDS, timelineEnd - timelineStart);
|
||||||
const startPercent = ((selectionStart - timelineStart) / span) * 100;
|
const startPercent = ((selectionStart - timelineStart) / span) * 100;
|
||||||
const endPercent = ((selectionEnd - 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(
|
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
||||||
'--selection-start',
|
'--selection-start',
|
||||||
`${clamp(startPercent, 0, 100)}%`,
|
`${clamp(startPercent, 0, 100)}%`,
|
||||||
@@ -131,12 +202,16 @@ export function createMediaTimingReviewModal(
|
|||||||
'--selection-end',
|
'--selection-end',
|
||||||
`${clamp(endPercent, 0, 100)}%`,
|
`${clamp(endPercent, 0, 100)}%`,
|
||||||
);
|
);
|
||||||
ctx.dom.mediaTimingReviewStartRange.min = String(timelineStart);
|
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
||||||
ctx.dom.mediaTimingReviewStartRange.max = String(timelineEnd);
|
'--original-start',
|
||||||
ctx.dom.mediaTimingReviewStartRange.value = String(selectionStart);
|
`${clamp(originalStartPercent, 0, 100)}%`,
|
||||||
ctx.dom.mediaTimingReviewEndRange.min = String(timelineStart);
|
);
|
||||||
ctx.dom.mediaTimingReviewEndRange.max = String(timelineEnd);
|
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
||||||
ctx.dom.mediaTimingReviewEndRange.value = String(selectionEnd);
|
'--original-end',
|
||||||
|
`${clamp(originalEndPercent, 0, 100)}%`,
|
||||||
|
);
|
||||||
|
renderHandle(ctx.dom.mediaTimingReviewStartHandle, selectionStart);
|
||||||
|
renderHandle(ctx.dom.mediaTimingReviewEndHandle, selectionEnd);
|
||||||
ctx.dom.mediaTimingReviewStartValue.textContent = formatMediaTimingTimestamp(selectionStart);
|
ctx.dom.mediaTimingReviewStartValue.textContent = formatMediaTimingTimestamp(selectionStart);
|
||||||
ctx.dom.mediaTimingReviewEndValue.textContent = formatMediaTimingTimestamp(selectionEnd);
|
ctx.dom.mediaTimingReviewEndValue.textContent = formatMediaTimingTimestamp(selectionEnd);
|
||||||
ctx.dom.mediaTimingReviewDuration.textContent = `${(selectionEnd - selectionStart).toFixed(2)}s`;
|
ctx.dom.mediaTimingReviewDuration.textContent = `${(selectionEnd - selectionStart).toFixed(2)}s`;
|
||||||
@@ -153,6 +228,70 @@ export function createMediaTimingReviewModal(
|
|||||||
payload?.mediaDuration !== undefined && timelineEnd >= payload.mediaDuration;
|
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 {
|
function updateSelection(nextStart: number, nextEnd: number): void {
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
const mediaEnd = payload.mediaDuration ?? Number.POSITIVE_INFINITY;
|
const mediaEnd = payload.mediaDuration ?? Number.POSITIVE_INFINITY;
|
||||||
@@ -172,6 +311,116 @@ export function createMediaTimingReviewModal(
|
|||||||
renderSelection();
|
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 {
|
function showEditor(): void {
|
||||||
ctx.dom.mediaTimingReviewCancelStep.classList.add('hidden');
|
ctx.dom.mediaTimingReviewCancelStep.classList.add('hidden');
|
||||||
ctx.dom.mediaTimingReviewEditor.classList.remove('hidden');
|
ctx.dom.mediaTimingReviewEditor.classList.remove('hidden');
|
||||||
@@ -195,7 +444,11 @@ export function createMediaTimingReviewModal(
|
|||||||
|
|
||||||
function closeResolvedReview(): void {
|
function closeResolvedReview(): void {
|
||||||
if (!ctx.state.mediaTimingReviewModalOpen) return;
|
if (!ctx.state.mediaTimingReviewModalOpen) return;
|
||||||
|
cancelDrag();
|
||||||
clearPreviewTimer();
|
clearPreviewTimer();
|
||||||
|
if (waveformTimer !== null) clearTimeout(waveformTimer);
|
||||||
|
waveformTimer = null;
|
||||||
|
waveformSequence += 1;
|
||||||
previewPlaying = false;
|
previewPlaying = false;
|
||||||
ctx.state.mediaTimingReviewModalOpen = false;
|
ctx.state.mediaTimingReviewModalOpen = false;
|
||||||
ctx.dom.mediaTimingReviewModal.classList.add('hidden');
|
ctx.dom.mediaTimingReviewModal.classList.add('hidden');
|
||||||
@@ -285,6 +538,7 @@ export function createMediaTimingReviewModal(
|
|||||||
|
|
||||||
function openMediaTimingReviewModal(nextPayload: MediaTimingReviewOpenPayload): void {
|
function openMediaTimingReviewModal(nextPayload: MediaTimingReviewOpenPayload): void {
|
||||||
previewRequest.invalidate();
|
previewRequest.invalidate();
|
||||||
|
cancelDrag();
|
||||||
payload = nextPayload;
|
payload = nextPayload;
|
||||||
selectionStart = nextPayload.selectionStartTime;
|
selectionStart = nextPayload.selectionStartTime;
|
||||||
selectionEnd = nextPayload.selectionEndTime;
|
selectionEnd = nextPayload.selectionEndTime;
|
||||||
@@ -298,6 +552,7 @@ export function createMediaTimingReviewModal(
|
|||||||
: nextPayload.kind === 'audio'
|
: nextPayload.kind === 'audio'
|
||||||
? 'Audio card'
|
? 'Audio card'
|
||||||
: 'Sentence card';
|
: 'Sentence card';
|
||||||
|
ctx.dom.mediaTimingReviewKind.dataset.kind = nextPayload.kind;
|
||||||
ctx.dom.mediaTimingReviewText.textContent = nextPayload.text;
|
ctx.dom.mediaTimingReviewText.textContent = nextPayload.text;
|
||||||
ctx.dom.mediaTimingReviewDiscard.textContent =
|
ctx.dom.mediaTimingReviewDiscard.textContent =
|
||||||
nextPayload.noteId !== undefined ? 'Delete card' : "Don't create card";
|
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.classList.remove('hidden');
|
||||||
ctx.dom.mediaTimingReviewModal.setAttribute('aria-hidden', 'false');
|
ctx.dom.mediaTimingReviewModal.setAttribute('aria-hidden', 'false');
|
||||||
window.electronAPI.notifyOverlayModalOpened('media-timing-review');
|
window.electronAPI.notifyOverlayModalOpened('media-timing-review');
|
||||||
ctx.dom.mediaTimingReviewPlay.focus();
|
ctx.dom.mediaTimingReviewStartHandle.focus();
|
||||||
|
queueWaveformLoad();
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandTimeline(direction: 'earlier' | 'later'): void {
|
function expandTimeline(direction: 'earlier' | 'later'): void {
|
||||||
@@ -325,6 +581,7 @@ export function createMediaTimingReviewModal(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
renderSelection();
|
renderSelection();
|
||||||
|
queueWaveformLoad(120);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMediaTimingReviewKeydown(event: KeyboardEvent): boolean {
|
function handleMediaTimingReviewKeydown(event: KeyboardEvent): boolean {
|
||||||
@@ -357,12 +614,19 @@ export function createMediaTimingReviewModal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function wireDomEvents(): void {
|
function wireDomEvents(): void {
|
||||||
ctx.dom.mediaTimingReviewStartRange.addEventListener('input', () => {
|
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
||||||
updateSelection(Number(ctx.dom.mediaTimingReviewStartRange.value), selectionEnd);
|
track.addEventListener('pointerdown', beginDrag);
|
||||||
});
|
track.addEventListener('pointermove', (event) => {
|
||||||
ctx.dom.mediaTimingReviewEndRange.addEventListener('input', () => {
|
if (drag?.pointerId === event.pointerId) applyDrag(event.clientX);
|
||||||
updateSelection(selectionStart, Number(ctx.dom.mediaTimingReviewEndRange.value));
|
|
||||||
});
|
});
|
||||||
|
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.mediaTimingReviewShowEarlier.addEventListener('click', () => expandTimeline('earlier'));
|
||||||
ctx.dom.mediaTimingReviewShowLater.addEventListener('click', () => expandTimeline('later'));
|
ctx.dom.mediaTimingReviewShowLater.addEventListener('click', () => expandTimeline('later'));
|
||||||
ctx.dom.mediaTimingReviewStartBack.addEventListener('click', () =>
|
ctx.dom.mediaTimingReviewStartBack.addEventListener('click', () =>
|
||||||
|
|||||||
+330
-90
@@ -1325,22 +1325,34 @@ body:focus-visible,
|
|||||||
|
|
||||||
/* Media timing review uses the Catppuccin Macchiato palette without opacity-shifted colors. */
|
/* Media timing review uses the Catppuccin Macchiato palette without opacity-shifted colors. */
|
||||||
.media-timing-review-modal {
|
.media-timing-review-modal {
|
||||||
background: rgba(24, 25, 38, 0.74);
|
background: rgba(24, 25, 38, 0.76);
|
||||||
backdrop-filter: blur(8px);
|
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 {
|
.media-timing-review-content {
|
||||||
width: min(760px, calc(100vw - 32px));
|
width: min(820px, calc(100vw - 32px));
|
||||||
max-height: min(760px, calc(100vh - 32px));
|
max-height: min(780px, calc(100vh - 32px));
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
gap: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid var(--ctp-surface1);
|
border: 1px solid var(--ctp-surface1);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: var(--ctp-base);
|
background: var(--ctp-base);
|
||||||
color: var(--ctp-text);
|
color: var(--ctp-text);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 26px 80px rgba(24, 25, 38, 0.74),
|
0 30px 90px rgba(24, 25, 38, 0.8),
|
||||||
0 0 0 1px rgba(183, 189, 248, 0.08) inset;
|
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);
|
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 {
|
.media-timing-review-header {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 2;
|
||||||
|
top: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
padding: 22px 24px 18px;
|
padding: 20px 24px 16px;
|
||||||
border-bottom: 1px solid var(--ctp-surface0);
|
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 {
|
.media-timing-review-heading {
|
||||||
margin-bottom: 3px;
|
display: flex;
|
||||||
color: var(--ctp-teal);
|
flex-wrap: wrap;
|
||||||
font-size: 11px;
|
align-items: center;
|
||||||
font-weight: 800;
|
gap: 10px;
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-title {
|
.media-timing-review-title {
|
||||||
color: var(--ctp-text);
|
color: var(--ctp-text);
|
||||||
font-size: 22px;
|
font-size: 21px;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
letter-spacing: -0.025em;
|
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 {
|
.media-timing-review-editor {
|
||||||
padding: 20px 24px 24px;
|
padding: 18px 24px 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-text {
|
.media-timing-review-text {
|
||||||
max-height: 108px;
|
max-height: 108px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
margin: 0 0 18px;
|
margin: 0 0 16px;
|
||||||
padding: 13px 16px;
|
padding: 12px 16px;
|
||||||
border-left: 3px solid var(--ctp-mauve);
|
border-left: 3px solid var(--ctp-mauve);
|
||||||
border-radius: 0 10px 10px 0;
|
border-radius: 0 10px 10px 0;
|
||||||
background: var(--ctp-mantle);
|
background: var(--ctp-mantle);
|
||||||
@@ -1402,9 +1440,9 @@ body:focus-visible,
|
|||||||
|
|
||||||
.media-timing-review-readout {
|
.media-timing-review-readout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 12px;
|
||||||
border: 1px solid var(--ctp-surface1);
|
border: 1px solid var(--ctp-surface1);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--ctp-mantle);
|
background: var(--ctp-mantle);
|
||||||
@@ -1414,8 +1452,8 @@ body:focus-visible,
|
|||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 3px;
|
||||||
padding: 11px 14px;
|
padding: 10px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-readout > div + div {
|
.media-timing-review-readout > div + div {
|
||||||
@@ -1423,10 +1461,10 @@ body:focus-visible,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-readout span {
|
.media-timing-review-readout span {
|
||||||
color: var(--ctp-overlay2);
|
color: var(--ctp-overlay1);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
letter-spacing: 0.09em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1439,12 +1477,22 @@ body:focus-visible,
|
|||||||
|
|
||||||
.media-timing-review-readout .media-timing-review-duration {
|
.media-timing-review-readout .media-timing-review-duration {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-width: 128px;
|
||||||
background: var(--ctp-surface0);
|
background: var(--ctp-surface0);
|
||||||
text-align: center;
|
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 {
|
.media-timing-review-timeline-shell {
|
||||||
padding: 14px 16px 12px;
|
padding: 12px 14px 10px;
|
||||||
border: 1px solid var(--ctp-surface1);
|
border: 1px solid var(--ctp-surface1);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: var(--ctp-mantle);
|
background: var(--ctp-mantle);
|
||||||
@@ -1471,79 +1519,202 @@ body:focus-visible,
|
|||||||
.media-timing-review-track {
|
.media-timing-review-track {
|
||||||
--selection-start: 20%;
|
--selection-start: 20%;
|
||||||
--selection-end: 80%;
|
--selection-end: 80%;
|
||||||
|
--original-start: 25%;
|
||||||
|
--original-end: 75%;
|
||||||
|
--playhead-duration: 1s;
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 44px;
|
height: 96px;
|
||||||
margin: 8px 0 9px;
|
margin: 8px 0 9px;
|
||||||
|
overflow: hidden;
|
||||||
border: 1px solid var(--ctp-surface2);
|
border: 1px solid var(--ctp-surface2);
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
background:
|
background:
|
||||||
|
linear-gradient(var(--ctp-surface1), var(--ctp-surface1)) center / 100% 1px no-repeat,
|
||||||
repeating-linear-gradient(
|
repeating-linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
transparent 0,
|
transparent 0,
|
||||||
transparent calc(10% - 1px),
|
transparent calc(10% - 1px),
|
||||||
var(--ctp-surface1) calc(10% - 1px),
|
color-mix(in srgb, var(--ctp-surface1) 65%, transparent) calc(10% - 1px),
|
||||||
var(--ctp-surface1) 10%
|
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 {
|
.media-timing-review-selected-range {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 7px;
|
z-index: 3;
|
||||||
bottom: 7px;
|
inset: 0 calc(100% - var(--selection-end)) 0 var(--selection-start);
|
||||||
left: var(--selection-start);
|
border-inline: 1px solid color-mix(in srgb, var(--ctp-teal) 65%, transparent);
|
||||||
right: calc(100% - var(--selection-end));
|
background: color-mix(in srgb, var(--ctp-teal) 9%, transparent);
|
||||||
border: 1px solid var(--ctp-teal);
|
box-shadow: 0 0 0 2000px color-mix(in srgb, var(--ctp-crust) 62%, transparent);
|
||||||
border-radius: 6px;
|
cursor: grab;
|
||||||
background: var(--ctp-sapphire);
|
|
||||||
box-shadow: 0 0 18px rgba(125, 196, 228, 0.24);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-range {
|
.media-timing-review-playhead {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
z-index: 5;
|
||||||
width: 100%;
|
top: 0;
|
||||||
height: 44px;
|
bottom: 0;
|
||||||
margin: 0;
|
left: var(--selection-start);
|
||||||
appearance: none;
|
width: 2px;
|
||||||
background: transparent;
|
background: var(--ctp-yellow);
|
||||||
|
box-shadow: 0 0 12px color-mix(in srgb, var(--ctp-yellow) 75%, transparent);
|
||||||
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-range::-webkit-slider-runnable-track {
|
.media-timing-review-track.is-previewing .media-timing-review-playhead {
|
||||||
height: 100%;
|
opacity: 1;
|
||||||
background: transparent;
|
animation: media-timing-review-playhead var(--playhead-duration) linear forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-range::-webkit-slider-thumb {
|
@keyframes media-timing-review-playhead {
|
||||||
width: 15px;
|
from {
|
||||||
height: 34px;
|
left: var(--selection-start);
|
||||||
margin-top: 4px;
|
}
|
||||||
appearance: none;
|
to {
|
||||||
border: 2px solid var(--ctp-base);
|
left: var(--selection-end);
|
||||||
border-radius: 5px;
|
}
|
||||||
background: var(--ctp-text);
|
}
|
||||||
box-shadow: 0 2px 7px var(--ctp-crust);
|
|
||||||
|
.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;
|
cursor: ew-resize;
|
||||||
pointer-events: auto;
|
touch-action: none;
|
||||||
|
transition: box-shadow 130ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-range::-moz-range-track {
|
/* Widens the grab area past the visible bracket without moving the clip edge. */
|
||||||
height: 100%;
|
.media-timing-review-handle::before {
|
||||||
background: transparent;
|
position: absolute;
|
||||||
|
inset: 0 -7px;
|
||||||
|
content: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-range::-moz-range-thumb {
|
.media-timing-review-handle::after {
|
||||||
width: 15px;
|
position: absolute;
|
||||||
height: 34px;
|
top: 50%;
|
||||||
border: 2px solid var(--ctp-base);
|
left: 50%;
|
||||||
border-radius: 5px;
|
width: 6px;
|
||||||
background: var(--ctp-text);
|
height: 18px;
|
||||||
box-shadow: 0 2px 7px var(--ctp-crust);
|
content: '';
|
||||||
cursor: ew-resize;
|
border-inline: 1px solid color-mix(in srgb, var(--ctp-crust) 55%, transparent);
|
||||||
pointer-events: auto;
|
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 {
|
.media-timing-review-expand-row span {
|
||||||
@@ -1558,7 +1729,7 @@ body:focus-visible,
|
|||||||
.media-timing-review-confirm-button,
|
.media-timing-review-confirm-button,
|
||||||
.media-timing-review-fine-control button {
|
.media-timing-review-fine-control button {
|
||||||
border: 1px solid var(--ctp-surface2);
|
border: 1px solid var(--ctp-surface2);
|
||||||
border-radius: 8px;
|
border-radius: 9px;
|
||||||
background: var(--ctp-surface0);
|
background: var(--ctp-surface0);
|
||||||
color: var(--ctp-text);
|
color: var(--ctp-text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -1572,8 +1743,9 @@ body:focus-visible,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-expand-button {
|
.media-timing-review-expand-button {
|
||||||
padding: 5px 9px;
|
padding: 5px 10px;
|
||||||
color: var(--ctp-sky);
|
color: var(--ctp-sky);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-expand-button:disabled,
|
.media-timing-review-expand-button:disabled,
|
||||||
@@ -1591,8 +1763,7 @@ body:focus-visible,
|
|||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-content button:focus-visible,
|
.media-timing-review-content button:focus-visible {
|
||||||
.media-timing-review-range:focus-visible::-webkit-slider-thumb {
|
|
||||||
outline: 2px solid var(--ctp-yellow);
|
outline: 2px solid var(--ctp-yellow);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
@@ -1601,34 +1772,37 @@ body:focus-visible,
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 12px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-fine-control {
|
.media-timing-review-fine-control {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto auto;
|
grid-template-columns: 1fr auto auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 6px;
|
||||||
padding: 9px 10px;
|
padding: 8px 10px;
|
||||||
border: 1px solid var(--ctp-surface0);
|
border: 1px solid var(--ctp-surface0);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
background: var(--ctp-mantle);
|
||||||
color: var(--ctp-subtext1);
|
color: var(--ctp-subtext1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-fine-control span {
|
.media-timing-review-fine-control span {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-fine-control button {
|
.media-timing-review-fine-control button {
|
||||||
min-width: 62px;
|
min-width: 58px;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-status {
|
.media-timing-review-status {
|
||||||
min-height: 21px;
|
min-height: 21px;
|
||||||
padding-top: 7px;
|
padding-top: 8px;
|
||||||
color: var(--ctp-teal);
|
color: var(--ctp-teal);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 620;
|
font-weight: 620;
|
||||||
@@ -1647,7 +1821,7 @@ body:focus-visible,
|
|||||||
|
|
||||||
.media-timing-review-footer {
|
.media-timing-review-footer {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding-top: 5px;
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-play-button,
|
.media-timing-review-play-button,
|
||||||
@@ -1655,20 +1829,38 @@ body:focus-visible,
|
|||||||
.media-timing-review-quiet-button,
|
.media-timing-review-quiet-button,
|
||||||
.media-timing-review-original-button,
|
.media-timing-review-original-button,
|
||||||
.media-timing-review-discard-button {
|
.media-timing-review-discard-button {
|
||||||
padding: 9px 14px;
|
padding: 9px 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-play-button {
|
.media-timing-review-play-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
border-color: var(--ctp-teal);
|
border-color: var(--ctp-teal);
|
||||||
background: var(--ctp-teal);
|
background: var(--ctp-teal);
|
||||||
color: var(--ctp-crust);
|
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 {
|
.media-timing-review-play-button.is-playing {
|
||||||
border-color: var(--ctp-yellow);
|
border-color: var(--ctp-yellow);
|
||||||
background: 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 {
|
.media-timing-review-confirm-button {
|
||||||
border-color: var(--ctp-blue);
|
border-color: var(--ctp-blue);
|
||||||
background: var(--ctp-blue);
|
background: var(--ctp-blue);
|
||||||
@@ -1686,20 +1878,48 @@ body:focus-visible,
|
|||||||
color: var(--ctp-crust);
|
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 {
|
.media-timing-review-cancel-step {
|
||||||
min-height: 330px;
|
min-height: 300px;
|
||||||
padding: 48px 38px 38px;
|
padding: 44px 38px 34px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-cancel-mark {
|
.media-timing-review-cancel-mark {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 54px;
|
width: 52px;
|
||||||
height: 54px;
|
height: 52px;
|
||||||
margin: 0 auto 16px;
|
margin: 0 auto 16px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 2px solid var(--ctp-yellow);
|
border: 2px solid var(--ctp-yellow);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--ctp-yellow) 12%, transparent);
|
||||||
color: var(--ctp-yellow);
|
color: var(--ctp-yellow);
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
font-weight: 760;
|
font-weight: 760;
|
||||||
@@ -1707,13 +1927,13 @@ body:focus-visible,
|
|||||||
|
|
||||||
.media-timing-review-cancel-step h2 {
|
.media-timing-review-cancel-step h2 {
|
||||||
color: var(--ctp-text);
|
color: var(--ctp-text);
|
||||||
font-size: 22px;
|
font-size: 21px;
|
||||||
font-weight: 760;
|
font-weight: 760;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-timing-review-cancel-step p {
|
.media-timing-review-cancel-step p {
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
margin: 8px auto 26px;
|
margin: 8px auto 24px;
|
||||||
color: var(--ctp-subtext0);
|
color: var(--ctp-subtext0);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -1726,6 +1946,17 @@ body:focus-visible,
|
|||||||
gap: 10px;
|
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 (max-width: 640px) {
|
||||||
.media-timing-review-content {
|
.media-timing-review-content {
|
||||||
width: calc(100vw - 18px);
|
width: calc(100vw - 18px);
|
||||||
@@ -1738,6 +1969,15 @@ body:focus-visible,
|
|||||||
padding-left: 15px;
|
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 {
|
.media-timing-review-fine-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,11 @@ export type RendererDom = {
|
|||||||
mediaTimingReviewTimelineStart: HTMLElement;
|
mediaTimingReviewTimelineStart: HTMLElement;
|
||||||
mediaTimingReviewTimelineEnd: HTMLElement;
|
mediaTimingReviewTimelineEnd: HTMLElement;
|
||||||
mediaTimingReviewSelectionTrack: HTMLDivElement;
|
mediaTimingReviewSelectionTrack: HTMLDivElement;
|
||||||
mediaTimingReviewStartRange: HTMLInputElement;
|
mediaTimingReviewWaveformLabel: HTMLElement;
|
||||||
mediaTimingReviewEndRange: HTMLInputElement;
|
mediaTimingReviewWaveformPath: SVGPathElement;
|
||||||
|
mediaTimingReviewSelectedRange: HTMLDivElement;
|
||||||
|
mediaTimingReviewStartHandle: HTMLDivElement;
|
||||||
|
mediaTimingReviewEndHandle: HTMLDivElement;
|
||||||
mediaTimingReviewShowEarlier: HTMLButtonElement;
|
mediaTimingReviewShowEarlier: HTMLButtonElement;
|
||||||
mediaTimingReviewShowLater: HTMLButtonElement;
|
mediaTimingReviewShowLater: HTMLButtonElement;
|
||||||
mediaTimingReviewStartBack: HTMLButtonElement;
|
mediaTimingReviewStartBack: HTMLButtonElement;
|
||||||
@@ -62,6 +65,7 @@ export type RendererDom = {
|
|||||||
mediaTimingReviewEndBack: HTMLButtonElement;
|
mediaTimingReviewEndBack: HTMLButtonElement;
|
||||||
mediaTimingReviewEndForward: HTMLButtonElement;
|
mediaTimingReviewEndForward: HTMLButtonElement;
|
||||||
mediaTimingReviewPlay: HTMLButtonElement;
|
mediaTimingReviewPlay: HTMLButtonElement;
|
||||||
|
mediaTimingReviewPlayLabel: HTMLElement;
|
||||||
mediaTimingReviewReset: HTMLButtonElement;
|
mediaTimingReviewReset: HTMLButtonElement;
|
||||||
mediaTimingReviewCancel: HTMLButtonElement;
|
mediaTimingReviewCancel: HTMLButtonElement;
|
||||||
mediaTimingReviewConfirm: HTMLButtonElement;
|
mediaTimingReviewConfirm: HTMLButtonElement;
|
||||||
@@ -174,8 +178,8 @@ export type RendererDom = {
|
|||||||
playlistBrowserClose: HTMLButtonElement;
|
playlistBrowserClose: HTMLButtonElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getRequiredElement<T extends HTMLElement>(id: string): T {
|
function getRequiredElement<T extends Element>(id: string): T {
|
||||||
const element = document.getElementById(id);
|
const element = document.querySelector(`#${id}`);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
throw new Error(`Missing required DOM element #${id}`);
|
throw new Error(`Missing required DOM element #${id}`);
|
||||||
}
|
}
|
||||||
@@ -246,10 +250,19 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
mediaTimingReviewSelectionTrack: getRequiredElement<HTMLDivElement>(
|
mediaTimingReviewSelectionTrack: getRequiredElement<HTMLDivElement>(
|
||||||
'mediaTimingReviewSelectionTrack',
|
'mediaTimingReviewSelectionTrack',
|
||||||
),
|
),
|
||||||
mediaTimingReviewStartRange: getRequiredElement<HTMLInputElement>(
|
mediaTimingReviewWaveformLabel: getRequiredElement<HTMLElement>(
|
||||||
'mediaTimingReviewStartRange',
|
'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: getRequiredElement<HTMLButtonElement>(
|
||||||
'mediaTimingReviewShowEarlier',
|
'mediaTimingReviewShowEarlier',
|
||||||
),
|
),
|
||||||
@@ -263,6 +276,7 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
'mediaTimingReviewEndForward',
|
'mediaTimingReviewEndForward',
|
||||||
),
|
),
|
||||||
mediaTimingReviewPlay: getRequiredElement<HTMLButtonElement>('mediaTimingReviewPlay'),
|
mediaTimingReviewPlay: getRequiredElement<HTMLButtonElement>('mediaTimingReviewPlay'),
|
||||||
|
mediaTimingReviewPlayLabel: getRequiredElement<HTMLElement>('mediaTimingReviewPlayLabel'),
|
||||||
mediaTimingReviewReset: getRequiredElement<HTMLButtonElement>('mediaTimingReviewReset'),
|
mediaTimingReviewReset: getRequiredElement<HTMLButtonElement>('mediaTimingReviewReset'),
|
||||||
mediaTimingReviewCancel: getRequiredElement<HTMLButtonElement>('mediaTimingReviewCancel'),
|
mediaTimingReviewCancel: getRequiredElement<HTMLButtonElement>('mediaTimingReviewCancel'),
|
||||||
mediaTimingReviewConfirm: getRequiredElement<HTMLButtonElement>('mediaTimingReviewConfirm'),
|
mediaTimingReviewConfirm: getRequiredElement<HTMLButtonElement>('mediaTimingReviewConfirm'),
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export const IPC_CHANNELS = {
|
|||||||
syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file',
|
syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file',
|
||||||
getChangelogSnapshot: 'changelog:get-snapshot',
|
getChangelogSnapshot: 'changelog:get-snapshot',
|
||||||
mediaTimingReviewPreview: 'media-timing-review:preview',
|
mediaTimingReviewPreview: 'media-timing-review:preview',
|
||||||
|
mediaTimingReviewWaveform: 'media-timing-review:waveform',
|
||||||
mediaTimingReviewStopPreview: 'media-timing-review:stop-preview',
|
mediaTimingReviewStopPreview: 'media-timing-review:stop-preview',
|
||||||
mediaTimingReviewResolve: 'media-timing-review:resolve',
|
mediaTimingReviewResolve: 'media-timing-review:resolve',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ export interface MediaTimingReviewPreviewRequest {
|
|||||||
endTime: number;
|
endTime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MediaTimingReviewWaveformRequest {
|
||||||
|
reviewId: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaTimingReviewWaveformResult extends MediaTimingReviewActionResult {
|
||||||
|
peaks?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MediaTimingReviewResolveRequest {
|
export interface MediaTimingReviewResolveRequest {
|
||||||
reviewId: string;
|
reviewId: string;
|
||||||
decision: MediaTimingReviewDecision;
|
decision: MediaTimingReviewDecision;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
MediaTimingReviewOpenPayload,
|
MediaTimingReviewOpenPayload,
|
||||||
MediaTimingReviewPreviewRequest,
|
MediaTimingReviewPreviewRequest,
|
||||||
MediaTimingReviewResolveRequest,
|
MediaTimingReviewResolveRequest,
|
||||||
|
MediaTimingReviewWaveformRequest,
|
||||||
|
MediaTimingReviewWaveformResult,
|
||||||
} from './anki';
|
} from './anki';
|
||||||
import type { ChangelogSnapshot } from './changelog';
|
import type { ChangelogSnapshot } from './changelog';
|
||||||
import type { ResolvedConfig, ShortcutsConfig } from './config';
|
import type { ResolvedConfig, ShortcutsConfig } from './config';
|
||||||
@@ -524,6 +526,9 @@ export interface ElectronAPI {
|
|||||||
previewMediaTimingReview: (
|
previewMediaTimingReview: (
|
||||||
request: MediaTimingReviewPreviewRequest,
|
request: MediaTimingReviewPreviewRequest,
|
||||||
) => Promise<MediaTimingReviewActionResult>;
|
) => Promise<MediaTimingReviewActionResult>;
|
||||||
|
getMediaTimingReviewWaveform: (
|
||||||
|
request: MediaTimingReviewWaveformRequest,
|
||||||
|
) => Promise<MediaTimingReviewWaveformResult>;
|
||||||
stopMediaTimingReviewPreview: (reviewId: string) => Promise<MediaTimingReviewActionResult>;
|
stopMediaTimingReviewPreview: (reviewId: string) => Promise<MediaTimingReviewActionResult>;
|
||||||
resolveMediaTimingReview: (
|
resolveMediaTimingReview: (
|
||||||
request: MediaTimingReviewResolveRequest,
|
request: MediaTimingReviewResolveRequest,
|
||||||
|
|||||||
Reference in New Issue
Block a user