mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-17 00:18:41 -07:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37d182ccea
|
||
|
|
504e15ae0e
|
||
|
|
66bf0db0fc
|
||
|
|
e22117fe83
|
||
|
|
1b1b062803
|
@@ -0,0 +1,4 @@
|
|||||||
|
type: added
|
||||||
|
area: mining
|
||||||
|
|
||||||
|
- Added optional pre-generation timing review for word, sentence, and audio cards with a compact speech-weighted waveform, clearly labeled mined-line boundaries, drag and keyboard adjustments, audio preview with a sweeping playhead, exact screenshot and AVIF timing, and explicit cancellation choices.
|
||||||
@@ -523,7 +523,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -569,6 +569,7 @@
|
|||||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
||||||
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
||||||
|
"reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false
|
||||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ Audio is extracted from the video file using the subtitle's start and end timest
|
|||||||
"generateAudio": true,
|
"generateAudio": true,
|
||||||
"normalizeAudio": true, // normalize generated clip loudness
|
"normalizeAudio": true, // normalize generated clip loudness
|
||||||
"mirrorMpvVolume": true, // apply the current mpv volume level
|
"mirrorMpvVolume": true, // apply the current mpv volume level
|
||||||
|
"reviewTiming": false, // review and adjust timing before media generation
|
||||||
"audioPadding": 0, // optional seconds before and after subtitle timing
|
"audioPadding": 0, // optional seconds before and after subtitle timing
|
||||||
"maxMediaDuration": 30 // cap total duration in seconds
|
"maxMediaDuration": 30 // cap total duration in seconds
|
||||||
}
|
}
|
||||||
@@ -178,6 +179,10 @@ 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. 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 shows the mined subtitle as a tinted band with labeled line-start and line-end rails, making adjacent dialogue easier to distinguish. SubMiner uses a center channel when one carries dialogue, then falls back to a speech-band mono mix. Waveform analysis failure leaves the timing controls available. The confirmed range is exact: SubMiner does not apply audio padding a second time. Static screenshots use its midpoint, and animated AVIF clips use the full confirmed range.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
### Screenshots (Static)
|
### Screenshots (Static)
|
||||||
|
|
||||||
A single frame is captured at the current playback position.
|
A single frame is captured at the current playback position.
|
||||||
|
|||||||
@@ -967,6 +967,7 @@ Enable automatic Anki card creation and updates with media generation:
|
|||||||
"animatedCrf": 35,
|
"animatedCrf": 35,
|
||||||
"normalizeAudio": true,
|
"normalizeAudio": true,
|
||||||
"mirrorMpvVolume": true,
|
"mirrorMpvVolume": true,
|
||||||
|
"reviewTiming": false,
|
||||||
"audioPadding": 0,
|
"audioPadding": 0,
|
||||||
"fallbackDuration": 3,
|
"fallbackDuration": 3,
|
||||||
"maxMediaDuration": 30
|
"maxMediaDuration": 30
|
||||||
@@ -1019,6 +1020,7 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||||
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. Changes apply live. |
|
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. Changes apply live. |
|
||||||
| `media.mirrorMpvVolume` | `true`, `false` | Apply mpv's cubic software-volume curve to each generated sentence-audio clip (default: `true`). This ignores mpv's separate mute state, falls back to unity scaling if volume cannot be read, and applies changes live. |
|
| `media.mirrorMpvVolume` | `true`, `false` | Apply mpv's cubic software-volume curve to each generated sentence-audio clip (default: `true`). This ignores mpv's separate mute state, falls back to unity scaling if volume cannot be read, and applies changes live. |
|
||||||
|
| `media.reviewTiming` | `true`, `false` | Pause playback and review word, sentence, and audio card timing before media generation (default: `false`). Clipboard updates and stats-dashboard mining do not open the review. |
|
||||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||||
|
|||||||
@@ -523,7 +523,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -569,6 +569,7 @@
|
|||||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
||||||
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
||||||
|
"reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false
|
||||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||||
|
|||||||
+38
-2
@@ -28,6 +28,8 @@ import {
|
|||||||
KikuMergePreviewResponse,
|
KikuMergePreviewResponse,
|
||||||
NotificationOptions,
|
NotificationOptions,
|
||||||
type WordCardKind,
|
type WordCardKind,
|
||||||
|
type MediaTimingReviewDecision,
|
||||||
|
type MediaTimingReviewRequest,
|
||||||
} from './types/anki';
|
} from './types/anki';
|
||||||
import { AiConfig } from './types/integrations';
|
import { AiConfig } from './types/integrations';
|
||||||
import type { KnownWordMaturityTier } from './types/subtitle';
|
import type { KnownWordMaturityTier } from './types/subtitle';
|
||||||
@@ -238,6 +240,9 @@ export class AnkiIntegration {
|
|||||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||||
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
||||||
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
||||||
|
private mediaTimingReviewCallback:
|
||||||
|
| ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>)
|
||||||
|
| null = null;
|
||||||
private noteIdRedirects = new Map<number, number>();
|
private noteIdRedirects = new Map<number, number>();
|
||||||
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
||||||
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
|
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
|
||||||
@@ -509,6 +514,7 @@ export class AnkiIntegration {
|
|||||||
findNotes: async (query, options) =>
|
findNotes: async (query, options) =>
|
||||||
(await this.client.findNotes(query, options)) as number[],
|
(await this.client.findNotes(query, options)) as number[],
|
||||||
retrieveMediaFile: (filename) => this.client.retrieveMediaFile(filename),
|
retrieveMediaFile: (filename) => this.client.retrieveMediaFile(filename),
|
||||||
|
deleteNotes: (noteIds) => this.client.deleteNotes(noteIds),
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: (
|
generateAudio: (
|
||||||
@@ -566,6 +572,7 @@ export class AnkiIntegration {
|
|||||||
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
||||||
getFallbackDurationSeconds: () => this.getFallbackDurationSeconds(),
|
getFallbackDurationSeconds: () => this.getFallbackDurationSeconds(),
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
|
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
|
||||||
|
removeKnownWordNote: (noteId) => this.removeKnownWordNote(noteId),
|
||||||
isUpdateInProgress: () => this.updateInProgress,
|
isUpdateInProgress: () => this.updateInProgress,
|
||||||
setUpdateInProgress: (value) => {
|
setUpdateInProgress: (value) => {
|
||||||
this.updateInProgress = value;
|
this.updateInProgress = value;
|
||||||
@@ -581,6 +588,7 @@ export class AnkiIntegration {
|
|||||||
recordCardsMinedCallback: (count, noteIds) => {
|
recordCardsMinedCallback: (count, noteIds) => {
|
||||||
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
||||||
},
|
},
|
||||||
|
reviewMediaTiming: (request) => this.reviewMediaTiming(request),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,12 +645,14 @@ export class AnkiIntegration {
|
|||||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
||||||
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
|
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
|
||||||
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
|
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
|
||||||
|
deleteNotes: (noteIds) => this.client.deleteNotes(noteIds),
|
||||||
},
|
},
|
||||||
getConfig: () => this.config,
|
getConfig: () => this.config,
|
||||||
getCurrentSubtitleText: () => this.mpvClient.currentSubText,
|
getCurrentSubtitleText: () => this.mpvClient.currentSubText,
|
||||||
getCurrentSubtitleStart: () => this.mpvClient.currentSubStart,
|
getCurrentSubtitleStart: () => this.mpvClient.currentSubStart,
|
||||||
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
|
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
|
||||||
|
removeKnownWordNote: (noteId) => this.removeKnownWordNote(noteId),
|
||||||
extractFields: (fields) => this.extractFields(fields),
|
extractFields: (fields) => this.extractFields(fields),
|
||||||
findDuplicateNote: (expression, excludeNoteId, noteInfo) =>
|
findDuplicateNote: (expression, excludeNoteId, noteInfo) =>
|
||||||
this.findDuplicateNote(expression, excludeNoteId, noteInfo),
|
this.findDuplicateNote(expression, excludeNoteId, noteInfo),
|
||||||
@@ -680,6 +690,7 @@ export class AnkiIntegration {
|
|||||||
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
|
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
|
||||||
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
|
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
|
||||||
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
|
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
|
||||||
|
reviewMediaTiming: (request) => this.reviewMediaTiming(request),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,6 +810,12 @@ export class AnkiIntegration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private removeKnownWordNote(noteId: number): void {
|
||||||
|
if (this.knownWordCache.removeNote(noteId)) {
|
||||||
|
this.notifyKnownWordCacheUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private notifyKnownWordCacheUpdated(): void {
|
private notifyKnownWordCacheUpdated(): void {
|
||||||
if (!this.knownWordCacheUpdatedCallback) {
|
if (!this.knownWordCacheUpdatedCallback) {
|
||||||
return;
|
return;
|
||||||
@@ -1039,7 +1056,7 @@ export class AnkiIntegration {
|
|||||||
videoPath,
|
videoPath,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
this.config.media?.audioPadding,
|
context?.mediaPaddingSeconds ?? this.config.media?.audioPadding,
|
||||||
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
||||||
this.config.media?.normalizeAudio !== false,
|
this.config.media?.normalizeAudio !== false,
|
||||||
await this.getMpvVolumeScale(),
|
await this.getMpvVolumeScale(),
|
||||||
@@ -1072,7 +1089,7 @@ export class AnkiIntegration {
|
|||||||
videoPath,
|
videoPath,
|
||||||
mediaRange.startTime,
|
mediaRange.startTime,
|
||||||
mediaRange.endTime,
|
mediaRange.endTime,
|
||||||
this.config.media?.audioPadding,
|
context?.mediaPaddingSeconds ?? this.config.media?.audioPadding,
|
||||||
{
|
{
|
||||||
fps: this.config.media?.animatedFps,
|
fps: this.config.media?.animatedFps,
|
||||||
maxWidth: this.config.media?.animatedMaxWidth,
|
maxWidth: this.config.media?.animatedMaxWidth,
|
||||||
@@ -1723,6 +1740,25 @@ export class AnkiIntegration {
|
|||||||
this.consumeSubtitleMiningContextCallback = callback;
|
this.consumeSubtitleMiningContextCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMediaTimingReviewCallback(
|
||||||
|
callback: ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>) | null,
|
||||||
|
): void {
|
||||||
|
this.mediaTimingReviewCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reviewMediaTiming(
|
||||||
|
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||||
|
): Promise<MediaTimingReviewDecision> {
|
||||||
|
if (this.config.media?.reviewTiming !== true || !this.mediaTimingReviewCallback) {
|
||||||
|
return { action: 'use-original' };
|
||||||
|
}
|
||||||
|
return await this.mediaTimingReviewCallback({
|
||||||
|
...request,
|
||||||
|
audioPadding: Math.max(0, this.config.media.audioPadding ?? 0),
|
||||||
|
maxMediaDuration: Math.max(0, this.config.media.maxMediaDuration ?? 30),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
resolveCurrentNoteId(noteId: number): number {
|
resolveCurrentNoteId(noteId: number): number {
|
||||||
let resolved = noteId;
|
let resolved = noteId;
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
|||||||
},
|
},
|
||||||
findNotes: async () => [42],
|
findNotes: async () => [42],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => Buffer.from('audio'),
|
generateAudio: async () => Buffer.from('audio'),
|
||||||
@@ -129,6 +130,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -201,6 +203,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [42],
|
findNotes: async () => [42],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
getEffectiveSentenceCardConfig: () => ({
|
getEffectiveSentenceCardConfig: () => ({
|
||||||
model: 'Sentence',
|
model: 'Sentence',
|
||||||
@@ -248,6 +251,7 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
|
|||||||
},
|
},
|
||||||
findNotes: async () => [42],
|
findNotes: async () => [42],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -335,6 +339,7 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
|||||||
},
|
},
|
||||||
findNotes: async () => [42],
|
findNotes: async () => [42],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async (path) => {
|
generateAudio: async (path) => {
|
||||||
@@ -383,3 +388,47 @@ test('createSentenceCard relies on Anki progress notification without standalone
|
|||||||
assert.deepEqual(progressMessages, ['Creating sentence card']);
|
assert.deepEqual(progressMessages, ['Creating sentence card']);
|
||||||
assert.deepEqual(statusMessages, []);
|
assert.deepEqual(statusMessages, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('discarding an audio-card timing review deletes the note before evicting its cache entry', async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const statusMessages: string[] = [];
|
||||||
|
const { service } = createManualUpdateService({
|
||||||
|
getMpvClient: () =>
|
||||||
|
({
|
||||||
|
currentVideoPath: '/video.mp4',
|
||||||
|
currentSubText: '字幕',
|
||||||
|
currentSubStart: 4,
|
||||||
|
currentSubEnd: 6,
|
||||||
|
currentTimePos: 5,
|
||||||
|
}) as never,
|
||||||
|
client: {
|
||||||
|
addNote: async () => 0,
|
||||||
|
addTags: async () => undefined,
|
||||||
|
notesInfo: async () => [
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: { Expression: { value: '単語' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updateNoteFields: async () => undefined,
|
||||||
|
storeMediaFile: async () => undefined,
|
||||||
|
findNotes: async () => [42],
|
||||||
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async (noteIds) => {
|
||||||
|
events.push(`delete:${noteIds.join(',')}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reviewMediaTiming: async () => ({ action: 'discard' }),
|
||||||
|
removeKnownWordNote: (noteId) => {
|
||||||
|
events.push(`cache:${noteId}`);
|
||||||
|
},
|
||||||
|
showStatusNotification: (message) => {
|
||||||
|
statusMessages.push(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.markLastCardAsAudioCard();
|
||||||
|
|
||||||
|
assert.deepEqual(events, ['delete:42', 'cache:42']);
|
||||||
|
assert.deepEqual(statusMessages, ['Card deleted.']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
const storedMedia: string[] = [];
|
const storedMedia: string[] = [];
|
||||||
const requestedProperties: string[] = [];
|
const requestedProperties: string[] = [];
|
||||||
const audioVolumeScales: Array<number | undefined> = [];
|
const audioVolumeScales: Array<number | undefined> = [];
|
||||||
|
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
|
||||||
|
|
||||||
const deps: CardCreationDeps = {
|
const deps: CardCreationDeps = {
|
||||||
getConfig: () =>
|
getConfig: () =>
|
||||||
@@ -73,17 +74,19 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
},
|
},
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async (
|
generateAudio: async (
|
||||||
_path,
|
_path,
|
||||||
_startTime,
|
startTime,
|
||||||
_endTime,
|
endTime,
|
||||||
_audioPadding,
|
audioPadding,
|
||||||
_audioStreamIndex,
|
_audioStreamIndex,
|
||||||
_normalizeAudio,
|
_normalizeAudio,
|
||||||
volumeScale,
|
volumeScale,
|
||||||
) => {
|
) => {
|
||||||
|
audioRanges.push({ start: startTime, end: endTime, padding: audioPadding });
|
||||||
audioVolumeScales.push(volumeScale);
|
audioVolumeScales.push(volumeScale);
|
||||||
return Buffer.from('audio');
|
return Buffer.from('audio');
|
||||||
},
|
},
|
||||||
@@ -122,17 +125,15 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
|
reviewMediaTiming: async () => ({ action: 'confirm', startTime: 11.4, endTime: 14.2 }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const created = await new CardCreationService(deps).createSentenceCard(
|
const service = new CardCreationService(deps);
|
||||||
'字幕',
|
const created = await service.createSentenceCard('字幕', 12, 14, 'Subtitle');
|
||||||
12,
|
|
||||||
14,
|
|
||||||
'Subtitle',
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(created, true);
|
assert.equal(created, true);
|
||||||
assert.deepEqual(addedFields[0], {
|
assert.deepEqual(addedFields[0], {
|
||||||
@@ -144,7 +145,12 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
assert.equal(storedMedia.length, 1);
|
assert.equal(storedMedia.length, 1);
|
||||||
assert.deepEqual(requestedProperties, ['volume']);
|
assert.deepEqual(requestedProperties, ['volume']);
|
||||||
assert.deepEqual(audioVolumeScales, [0.4 ** 3]);
|
assert.deepEqual(audioVolumeScales, [0.4 ** 3]);
|
||||||
|
assert.deepEqual(audioRanges, [{ start: 11.4, end: 14.2, padding: 0 }]);
|
||||||
const mediaUpdate = updatedFields.find((fields) => 'SentenceAudio' in fields);
|
const mediaUpdate = updatedFields.find((fields) => 'SentenceAudio' in fields);
|
||||||
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
||||||
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
||||||
|
|
||||||
|
deps.reviewMediaTiming = async () => ({ action: 'discard' });
|
||||||
|
assert.equal(await service.createSentenceCard('作らない', 20, 22), false);
|
||||||
|
assert.equal(addedFields.length, 1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ test('CardCreationService counts locally created sentence cards', async () => {
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => null,
|
generateAudio: async () => null,
|
||||||
@@ -74,6 +75,7 @@ test('CardCreationService counts locally created sentence cards', async () => {
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -139,6 +141,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => null,
|
generateAudio: async () => null,
|
||||||
@@ -173,6 +176,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => {
|
trackLastAddedNoteId: () => {
|
||||||
@@ -238,6 +242,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => null,
|
generateAudio: async () => null,
|
||||||
@@ -272,6 +277,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
recordCardsMinedCallback: () => {
|
recordCardsMinedCallback: () => {
|
||||||
@@ -348,6 +354,7 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async (path) => {
|
generateAudio: async (path) => {
|
||||||
@@ -392,6 +399,7 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -454,6 +462,7 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async (path, _startTime, _endTime, _padding, audioStreamIndex) => {
|
generateAudio: async (path, _startTime, _endTime, _padding, audioStreamIndex) => {
|
||||||
@@ -495,6 +504,7 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -590,6 +600,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => {
|
generateAudio: async () => {
|
||||||
@@ -634,6 +645,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -701,6 +713,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => null,
|
generateAudio: async () => null,
|
||||||
@@ -733,6 +746,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -790,6 +804,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => null,
|
generateAudio: async () => null,
|
||||||
@@ -822,6 +837,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import {
|
|||||||
getConfiguredWordFieldName,
|
getConfiguredWordFieldName,
|
||||||
getPreferredWordValueFromExtractedFields,
|
getPreferredWordValueFromExtractedFields,
|
||||||
} from '../anki-field-config';
|
} from '../anki-field-config';
|
||||||
import { AnkiConnectConfig, type CardKind, type WordCardKind } from '../types/anki';
|
import {
|
||||||
|
AnkiConnectConfig,
|
||||||
|
type CardKind,
|
||||||
|
type MediaTimingReviewDecision,
|
||||||
|
type MediaTimingReviewRequest,
|
||||||
|
type WordCardKind,
|
||||||
|
} from '../types/anki';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
import type { MediaInput } from '../media-input';
|
import type { MediaInput } from '../media-input';
|
||||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||||
@@ -55,6 +61,7 @@ interface CardCreationClient {
|
|||||||
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
||||||
findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>;
|
findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>;
|
||||||
retrieveMediaFile(filename: string): Promise<string>;
|
retrieveMediaFile(filename: string): Promise<string>;
|
||||||
|
deleteNotes(noteIds: number[]): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CardCreationMediaGenerator {
|
interface CardCreationMediaGenerator {
|
||||||
@@ -138,12 +145,16 @@ interface CardCreationDeps {
|
|||||||
};
|
};
|
||||||
getFallbackDurationSeconds: () => number;
|
getFallbackDurationSeconds: () => number;
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
||||||
|
removeKnownWordNote: (noteId: number) => void;
|
||||||
isUpdateInProgress: () => boolean;
|
isUpdateInProgress: () => boolean;
|
||||||
setUpdateInProgress: (value: boolean) => void;
|
setUpdateInProgress: (value: boolean) => void;
|
||||||
trackLastAddedNoteId?: (noteId: number) => void;
|
trackLastAddedNoteId?: (noteId: number) => void;
|
||||||
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||||
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
|
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
|
||||||
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
||||||
|
reviewMediaTiming?: (
|
||||||
|
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||||
|
) => Promise<MediaTimingReviewDecision>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CardCreationService {
|
export class CardCreationService {
|
||||||
@@ -260,6 +271,7 @@ export class CardCreationService {
|
|||||||
fields,
|
fields,
|
||||||
this.deps.getConfig(),
|
this.deps.getConfig(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
|
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
|
||||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||||
const sentenceField = sentenceCardConfig.sentenceField;
|
const sentenceField = sentenceCardConfig.sentenceField;
|
||||||
@@ -451,6 +463,27 @@ export class CardCreationService {
|
|||||||
this.deps.getConfig(),
|
this.deps.getConfig(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const timingDecision = this.deps.reviewMediaTiming
|
||||||
|
? await this.deps.reviewMediaTiming({
|
||||||
|
kind: 'audio',
|
||||||
|
text: mpvClient.currentSubText,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
noteId,
|
||||||
|
})
|
||||||
|
: ({ action: 'use-original' } as const);
|
||||||
|
if (timingDecision.action === 'discard') {
|
||||||
|
await this.deps.client.deleteNotes([noteId]);
|
||||||
|
this.deps.removeKnownWordNote(noteId);
|
||||||
|
this.deps.showStatusNotification('Card deleted.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const exactReviewedRange = timingDecision.action === 'confirm';
|
||||||
|
if (timingDecision.action === 'confirm') {
|
||||||
|
startTime = timingDecision.startTime;
|
||||||
|
endTime = timingDecision.endTime;
|
||||||
|
}
|
||||||
|
|
||||||
const updatedFields: Record<string, string> = {};
|
const updatedFields: Record<string, string> = {};
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
let miscInfoFilename: string | null = null;
|
let miscInfoFilename: string | null = null;
|
||||||
@@ -471,6 +504,7 @@ export class CardCreationService {
|
|||||||
mpvClient.currentVideoPath,
|
mpvClient.currentVideoPath,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
|
exactReviewedRange ? 0 : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
@@ -492,6 +526,7 @@ export class CardCreationService {
|
|||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
animatedLeadInSeconds,
|
animatedLeadInSeconds,
|
||||||
|
exactReviewedRange,
|
||||||
);
|
);
|
||||||
|
|
||||||
const imageField = this.deps.getConfig().fields?.image;
|
const imageField = this.deps.getConfig().fields?.image;
|
||||||
@@ -564,6 +599,24 @@ export class CardCreationService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
||||||
|
const timingDecision = this.deps.reviewMediaTiming
|
||||||
|
? await this.deps.reviewMediaTiming({
|
||||||
|
kind: 'sentence',
|
||||||
|
text: sentence,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
})
|
||||||
|
: ({ action: 'use-original' } as const);
|
||||||
|
if (timingDecision.action === 'discard') {
|
||||||
|
this.deps.showStatusNotification('Card creation cancelled.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const exactReviewedRange = timingDecision.action === 'confirm';
|
||||||
|
if (timingDecision.action === 'confirm') {
|
||||||
|
startTime = timingDecision.startTime;
|
||||||
|
endTime = timingDecision.endTime;
|
||||||
|
}
|
||||||
|
|
||||||
const config = this.deps.getConfig();
|
const config = this.deps.getConfig();
|
||||||
const generateAudio = shouldGenerateAudio(config);
|
const generateAudio = shouldGenerateAudio(config);
|
||||||
const generateImage = shouldGenerateImage(config);
|
const generateImage = shouldGenerateImage(config);
|
||||||
@@ -732,6 +785,7 @@ export class CardCreationService {
|
|||||||
generateAudio,
|
generateAudio,
|
||||||
generateImage,
|
generateImage,
|
||||||
volumeScale,
|
volumeScale,
|
||||||
|
...(exactReviewedRange ? { mediaPaddingSeconds: 0 } : {}),
|
||||||
});
|
});
|
||||||
await this.deps.showNotification(noteId, label, 'media queued');
|
await this.deps.showNotification(noteId, label, 'media queued');
|
||||||
return true;
|
return true;
|
||||||
@@ -747,7 +801,12 @@ export class CardCreationService {
|
|||||||
try {
|
try {
|
||||||
const audioFilename = this.generateAudioFilename();
|
const audioFilename = this.generateAudioFilename();
|
||||||
const audioBuffer = audioSourcePath
|
const audioBuffer = audioSourcePath
|
||||||
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
|
? await this.mediaGenerateAudio(
|
||||||
|
audioSourcePath,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
exactReviewedRange ? 0 : undefined,
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
@@ -765,7 +824,13 @@ export class CardCreationService {
|
|||||||
if (generateImage) {
|
if (generateImage) {
|
||||||
try {
|
try {
|
||||||
const imageFilename = this.generateImageFilename();
|
const imageFilename = this.generateImageFilename();
|
||||||
const imageBuffer = await this.generateImageBuffer(videoPath!, startTime, endTime);
|
const imageBuffer = await this.generateImageBuffer(
|
||||||
|
videoPath!,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
0,
|
||||||
|
exactReviewedRange,
|
||||||
|
);
|
||||||
|
|
||||||
const imageField = config.fields?.image;
|
const imageField = config.fields?.image;
|
||||||
if (imageBuffer && imageField) {
|
if (imageBuffer && imageField) {
|
||||||
@@ -833,6 +898,7 @@ export class CardCreationService {
|
|||||||
videoPath: MediaInput,
|
videoPath: MediaInput,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
|
audioPaddingOverride?: number,
|
||||||
): Promise<Buffer | null> {
|
): Promise<Buffer | null> {
|
||||||
const mpvClient = this.deps.getMpvClient();
|
const mpvClient = this.deps.getMpvClient();
|
||||||
if (!mpvClient) {
|
if (!mpvClient) {
|
||||||
@@ -843,7 +909,7 @@ export class CardCreationService {
|
|||||||
videoPath,
|
videoPath,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
this.deps.getConfig().media?.audioPadding,
|
audioPaddingOverride ?? this.deps.getConfig().media?.audioPadding,
|
||||||
resolveAudioStreamIndexForMediaGeneration(
|
resolveAudioStreamIndexForMediaGeneration(
|
||||||
videoPath,
|
videoPath,
|
||||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||||
@@ -861,13 +927,16 @@ export class CardCreationService {
|
|||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
animatedLeadInSeconds = 0,
|
animatedLeadInSeconds = 0,
|
||||||
|
exactReviewedRange = false,
|
||||||
): Promise<Buffer | null> {
|
): Promise<Buffer | null> {
|
||||||
const mpvClient = this.deps.getMpvClient();
|
const mpvClient = this.deps.getMpvClient();
|
||||||
if (!mpvClient) {
|
if (!mpvClient) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = mpvClient.currentTimePos || 0;
|
const timestamp = exactReviewedRange
|
||||||
|
? startTime + (endTime - startTime) / 2
|
||||||
|
: mpvClient.currentTimePos || 0;
|
||||||
|
|
||||||
if (this.deps.getConfig().media?.imageType === 'avif') {
|
if (this.deps.getConfig().media?.imageType === 'avif') {
|
||||||
let imageStart = startTime;
|
let imageStart = startTime;
|
||||||
@@ -883,7 +952,7 @@ export class CardCreationService {
|
|||||||
videoPath,
|
videoPath,
|
||||||
imageStart,
|
imageStart,
|
||||||
imageEnd,
|
imageEnd,
|
||||||
this.deps.getConfig().media?.audioPadding,
|
exactReviewedRange ? 0 : this.deps.getConfig().media?.audioPadding,
|
||||||
{
|
{
|
||||||
fps: this.deps.getConfig().media?.animatedFps,
|
fps: this.deps.getConfig().media?.animatedFps,
|
||||||
maxWidth: this.deps.getConfig().media?.animatedMaxWidth,
|
maxWidth: this.deps.getConfig().media?.animatedMaxWidth,
|
||||||
|
|||||||
@@ -261,6 +261,32 @@ test('KnownWordCacheManager invalidates persisted cache when fields.word changes
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('KnownWordCacheManager removes a deleted note from memory and persisted state', () => {
|
||||||
|
const config: AnkiConnectConfig = {
|
||||||
|
fields: { word: 'Word' },
|
||||||
|
knownWords: { highlightEnabled: true },
|
||||||
|
};
|
||||||
|
const { manager, statePath, cleanup } = createKnownWordCacheHarness(config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
manager.appendFromNoteInfo({
|
||||||
|
noteId: 42,
|
||||||
|
fields: { Word: { value: '猫' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(manager.removeNote(42), true);
|
||||||
|
assert.equal(manager.removeNote(42), false);
|
||||||
|
assert.equal(manager.isKnownWord('猫'), false);
|
||||||
|
|
||||||
|
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
||||||
|
notes?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
assert.deepEqual(persisted.notes, {});
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('KnownWordCacheManager refresh incrementally reconciles deleted and edited note words', async () => {
|
test('KnownWordCacheManager refresh incrementally reconciles deleted and edited note words', async () => {
|
||||||
const config: AnkiConnectConfig = {
|
const config: AnkiConnectConfig = {
|
||||||
fields: {
|
fields: {
|
||||||
|
|||||||
@@ -350,6 +350,17 @@ export class KnownWordCacheManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeNote(noteId: number): boolean {
|
||||||
|
if (!this.noteEntriesById.has(noteId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.removeNoteSnapshot(noteId);
|
||||||
|
this.persistKnownWordCacheState();
|
||||||
|
log.info('Known-word cache removed deleted note', `noteId=${noteId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
clearKnownWordCacheState(): void {
|
clearKnownWordCacheState(): void {
|
||||||
this.clearInMemoryState();
|
this.clearInMemoryState();
|
||||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function createWorkflowHarness() {
|
|||||||
updates.push({ noteId, fields });
|
updates.push({ noteId, fields });
|
||||||
},
|
},
|
||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
getConfig: () => ({
|
getConfig: () => ({
|
||||||
fields: {
|
fields: {
|
||||||
@@ -61,6 +62,7 @@ function createWorkflowHarness() {
|
|||||||
kikuFieldGrouping: 'disabled' as const,
|
kikuFieldGrouping: 'disabled' as const,
|
||||||
}),
|
}),
|
||||||
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
|
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
|
||||||
|
removeKnownWordNote: (_noteId: number) => undefined,
|
||||||
extractFields: (fields: Record<string, { value: string }>) => {
|
extractFields: (fields: Record<string, { value: string }>) => {
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
for (const [key, value] of Object.entries(fields)) {
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
@@ -592,3 +594,62 @@ test('NoteUpdateWorkflow queues media updates when YouTube cache is pending', as
|
|||||||
assert.equal(queuedUpdates[0]?.context, undefined);
|
assert.equal(queuedUpdates[0]?.context, undefined);
|
||||||
assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]);
|
assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow deletes an existing word card when timing review discards it', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
const deletedNoteIds: number[][] = [];
|
||||||
|
const removedKnownWordNoteIds: number[] = [];
|
||||||
|
let appendedKnownWords = false;
|
||||||
|
harness.deps.captureSubtitleMediaContext = () => ({
|
||||||
|
source: 'overlay',
|
||||||
|
text: 'subtitle-text',
|
||||||
|
startTime: 4,
|
||||||
|
endTime: 6,
|
||||||
|
});
|
||||||
|
harness.deps.client.deleteNotes = async (noteIds) => {
|
||||||
|
deletedNoteIds.push(noteIds);
|
||||||
|
};
|
||||||
|
harness.deps.appendKnownWordsFromNoteInfo = () => {
|
||||||
|
appendedKnownWords = true;
|
||||||
|
};
|
||||||
|
harness.deps.removeKnownWordNote = (noteId) => {
|
||||||
|
removedKnownWordNoteIds.push(noteId);
|
||||||
|
};
|
||||||
|
harness.deps.reviewMediaTiming = async () => ({ action: 'discard' });
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.deepEqual(deletedNoteIds, [[42]]);
|
||||||
|
assert.deepEqual(removedKnownWordNoteIds, [42]);
|
||||||
|
assert.equal(appendedKnownWords, false);
|
||||||
|
assert.deepEqual(harness.updates, []);
|
||||||
|
assert.deepEqual(harness.notifications, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
const statusMessages: string[] = [];
|
||||||
|
let removedKnownWord = false;
|
||||||
|
harness.deps.captureSubtitleMediaContext = () => ({
|
||||||
|
source: 'overlay',
|
||||||
|
text: 'subtitle-text',
|
||||||
|
startTime: 4,
|
||||||
|
endTime: 6,
|
||||||
|
});
|
||||||
|
harness.deps.client.deleteNotes = async () => {
|
||||||
|
throw new Error('delete failed');
|
||||||
|
};
|
||||||
|
harness.deps.removeKnownWordNote = () => {
|
||||||
|
removedKnownWord = true;
|
||||||
|
};
|
||||||
|
harness.deps.showOsdNotification = (message) => {
|
||||||
|
statusMessages.push(message);
|
||||||
|
};
|
||||||
|
harness.deps.reviewMediaTiming = async () => ({ action: 'discard' });
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(removedKnownWord, false);
|
||||||
|
assert.deepEqual(statusMessages, ['Card deletion failed: delete failed']);
|
||||||
|
assert.ok(harness.warnings.length === 0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
import type { CardKind, WordCardKind } from '../types/anki';
|
import type {
|
||||||
|
CardKind,
|
||||||
|
MediaTimingReviewDecision,
|
||||||
|
MediaTimingReviewRequest,
|
||||||
|
WordCardKind,
|
||||||
|
} from '../types/anki';
|
||||||
import { resolveWordCardKind } from './note-field-utils';
|
import { resolveWordCardKind } from './note-field-utils';
|
||||||
|
|
||||||
export interface NoteUpdateWorkflowNoteInfo {
|
export interface NoteUpdateWorkflowNoteInfo {
|
||||||
@@ -14,6 +19,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
notesInfo(noteIds: number[]): Promise<unknown>;
|
notesInfo(noteIds: number[]): Promise<unknown>;
|
||||||
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
|
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
|
||||||
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
||||||
|
deleteNotes(noteIds: number[]): Promise<void>;
|
||||||
};
|
};
|
||||||
getConfig: () => {
|
getConfig: () => {
|
||||||
fields?: {
|
fields?: {
|
||||||
@@ -43,6 +49,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
wordCardKind?: WordCardKind;
|
wordCardKind?: WordCardKind;
|
||||||
};
|
};
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
||||||
|
removeKnownWordNote: (noteId: number) => void;
|
||||||
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
||||||
findDuplicateNote: (
|
findDuplicateNote: (
|
||||||
expression: string,
|
expression: string,
|
||||||
@@ -102,6 +109,9 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
logWarn: (message: string, ...args: unknown[]) => void;
|
logWarn: (message: string, ...args: unknown[]) => void;
|
||||||
logInfo: (message: string, ...args: unknown[]) => void;
|
logInfo: (message: string, ...args: unknown[]) => void;
|
||||||
logError: (message: string, ...args: unknown[]) => void;
|
logError: (message: string, ...args: unknown[]) => void;
|
||||||
|
reviewMediaTiming?: (
|
||||||
|
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||||
|
) => Promise<MediaTimingReviewDecision>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSubtitleContextText(text: string): string {
|
function normalizeSubtitleContextText(text: string): string {
|
||||||
@@ -171,7 +181,6 @@ export class NoteUpdateWorkflow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const noteInfo = notesInfo[0]!;
|
const noteInfo = notesInfo[0]!;
|
||||||
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
|
||||||
const fields = this.deps.extractFields(noteInfo.fields);
|
const fields = this.deps.extractFields(noteInfo.fields);
|
||||||
const config = this.deps.getConfig();
|
const config = this.deps.getConfig();
|
||||||
|
|
||||||
@@ -207,10 +216,45 @@ export class NoteUpdateWorkflow {
|
|||||||
// Audio and image generation run sequentially and audio extraction can take tens of
|
// Audio and image generation run sequentially and audio extraction can take tens of
|
||||||
// seconds, so resolve the clip range exactly once up front; reading live mpv sub
|
// seconds, so resolve the clip range exactly once up front; reading live mpv sub
|
||||||
// timings per generator clips whichever line is on screen when each one starts.
|
// timings per generator clips whichever line is on screen when each one starts.
|
||||||
const mediaTimingContext =
|
let mediaTimingContext =
|
||||||
subtitleMiningContext ?? this.deps.captureSubtitleMediaContext?.() ?? null;
|
subtitleMiningContext ?? this.deps.captureSubtitleMediaContext?.() ?? null;
|
||||||
const noteLabel = hasExpressionText ? expressionText : noteId;
|
const noteLabel = hasExpressionText ? expressionText : noteId;
|
||||||
|
|
||||||
|
if (mediaTimingContext) {
|
||||||
|
const timingDecision = this.deps.reviewMediaTiming
|
||||||
|
? await this.deps.reviewMediaTiming({
|
||||||
|
kind: 'word',
|
||||||
|
text: mediaTimingContext.text,
|
||||||
|
startTime: mediaTimingContext.startTime,
|
||||||
|
endTime: mediaTimingContext.endTime,
|
||||||
|
noteId,
|
||||||
|
})
|
||||||
|
: ({ action: 'use-original' } as const);
|
||||||
|
if (timingDecision.action === 'discard') {
|
||||||
|
try {
|
||||||
|
await this.deps.client.deleteNotes([noteId]);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.deps.logError('Failed to delete discarded card:', message);
|
||||||
|
this.deps.showOsdNotification(`Card deletion failed: ${message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.deps.removeKnownWordNote(noteId);
|
||||||
|
this.deps.showOsdNotification('Card deleted.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (timingDecision.action === 'confirm') {
|
||||||
|
mediaTimingContext = {
|
||||||
|
...mediaTimingContext,
|
||||||
|
startTime: timingDecision.startTime,
|
||||||
|
endTime: timingDecision.endTime,
|
||||||
|
mediaPaddingSeconds: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
||||||
|
|
||||||
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
|
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
|
||||||
if (sentenceField && currentSubtitleText) {
|
if (sentenceField && currentSubtitleText) {
|
||||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ export class PendingYoutubeMediaQueue {
|
|||||||
generateAudio: shouldGenerateAudio(config),
|
generateAudio: shouldGenerateAudio(config),
|
||||||
generateImage: shouldGenerateImage(config),
|
generateImage: shouldGenerateImage(config),
|
||||||
volumeScale,
|
volumeScale,
|
||||||
|
...(job.context?.mediaPaddingSeconds !== undefined
|
||||||
|
? { mediaPaddingSeconds: job.context.mediaPaddingSeconds }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -275,7 +278,7 @@ export class PendingYoutubeMediaQueue {
|
|||||||
cachedMediaInput,
|
cachedMediaInput,
|
||||||
job.startTime,
|
job.startTime,
|
||||||
job.endTime,
|
job.endTime,
|
||||||
config.media?.audioPadding,
|
job.mediaPaddingSeconds ?? config.media?.audioPadding,
|
||||||
undefined,
|
undefined,
|
||||||
config.media?.normalizeAudio !== false,
|
config.media?.normalizeAudio !== false,
|
||||||
job.volumeScale,
|
job.volumeScale,
|
||||||
@@ -309,6 +312,7 @@ export class PendingYoutubeMediaQueue {
|
|||||||
job.startTime,
|
job.startTime,
|
||||||
job.endTime,
|
job.endTime,
|
||||||
animatedLeadInSeconds,
|
animatedLeadInSeconds,
|
||||||
|
job.mediaPaddingSeconds,
|
||||||
);
|
);
|
||||||
if (imageBuffer) {
|
if (imageBuffer) {
|
||||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||||
@@ -369,6 +373,7 @@ export class PendingYoutubeMediaQueue {
|
|||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
animatedLeadInSeconds = 0,
|
animatedLeadInSeconds = 0,
|
||||||
|
mediaPaddingSeconds?: number,
|
||||||
): Promise<Buffer | null> {
|
): Promise<Buffer | null> {
|
||||||
const config = this.deps.getConfig();
|
const config = this.deps.getConfig();
|
||||||
if (config.media?.imageType === 'avif') {
|
if (config.media?.imageType === 'avif') {
|
||||||
@@ -376,7 +381,7 @@ export class PendingYoutubeMediaQueue {
|
|||||||
videoPath,
|
videoPath,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
config.media?.audioPadding,
|
mediaPaddingSeconds ?? config.media?.audioPadding,
|
||||||
{
|
{
|
||||||
fps: config.media?.animatedFps,
|
fps: config.media?.animatedFps,
|
||||||
maxWidth: config.media?.animatedMaxWidth,
|
maxWidth: config.media?.animatedMaxWidth,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface PendingYoutubeMediaUpdate {
|
|||||||
generateAudio: boolean;
|
generateAudio: boolean;
|
||||||
generateImage: boolean;
|
generateImage: boolean;
|
||||||
volumeScale?: number;
|
volumeScale?: number;
|
||||||
|
mediaPaddingSeconds?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimToNonEmptyString(value: unknown): string | null {
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
syncAnimatedImageToWordAudio: true,
|
syncAnimatedImageToWordAudio: true,
|
||||||
normalizeAudio: true,
|
normalizeAudio: true,
|
||||||
mirrorMpvVolume: true,
|
mirrorMpvVolume: true,
|
||||||
|
reviewTiming: false,
|
||||||
audioPadding: 0,
|
audioPadding: 0,
|
||||||
fallbackDuration: 3.0,
|
fallbackDuration: 3.0,
|
||||||
maxMediaDuration: 30,
|
maxMediaDuration: 30,
|
||||||
|
|||||||
@@ -196,6 +196,13 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
"Apply mpv's current software volume curve to generated sentence audio. Changes apply live.",
|
"Apply mpv's current software volume curve to generated sentence audio. Changes apply live.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ankiConnect.media.reviewTiming',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.ankiConnect.media.reviewTiming,
|
||||||
|
description:
|
||||||
|
'Review and preview subtitle media timing before SubMiner creates or enriches a mined card.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.media.generateImage',
|
path: 'ankiConnect.media.generateImage',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
title: 'AnkiConnect Integration',
|
title: 'AnkiConnect Integration',
|
||||||
description: ['Automatic Anki updates and media generation options.'],
|
description: ['Automatic Anki updates and media generation options.'],
|
||||||
notes: [
|
notes: [
|
||||||
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
|
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
|
||||||
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
||||||
'Most other AnkiConnect settings still require restart.',
|
'Most other AnkiConnect settings still require restart.',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -21,6 +21,34 @@ function makeContext(ankiConnect: unknown): {
|
|||||||
return { context, warnings };
|
return { context, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('media timing review is disabled by default and accepts a boolean override', () => {
|
||||||
|
const defaultContext = makeContext({});
|
||||||
|
applyAnkiConnectResolution(defaultContext.context);
|
||||||
|
assert.equal(defaultContext.context.resolved.ankiConnect.media.reviewTiming, false);
|
||||||
|
|
||||||
|
const enabledContext = makeContext({ media: { reviewTiming: true } });
|
||||||
|
applyAnkiConnectResolution(enabledContext.context);
|
||||||
|
assert.equal(enabledContext.context.resolved.ankiConnect.media.reviewTiming, true);
|
||||||
|
assert.deepEqual(enabledContext.warnings, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modern media duration accepts zero as the disabled cap sentinel', () => {
|
||||||
|
const disabledCap = makeContext({ media: { maxMediaDuration: 0 } });
|
||||||
|
applyAnkiConnectResolution(disabledCap.context);
|
||||||
|
assert.equal(disabledCap.context.resolved.ankiConnect.media.maxMediaDuration, 0);
|
||||||
|
assert.deepEqual(disabledCap.warnings, []);
|
||||||
|
|
||||||
|
const invalidCap = makeContext({ media: { maxMediaDuration: -1 } });
|
||||||
|
applyAnkiConnectResolution(invalidCap.context);
|
||||||
|
assert.equal(
|
||||||
|
invalidCap.context.resolved.ankiConnect.media.maxMediaDuration,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.maxMediaDuration,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
invalidCap.warnings.some((warning) => warning.path === 'ankiConnect.media.maxMediaDuration'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('modern invalid knownWords.highlightEnabled warns modern key and does not fallback to legacy', () => {
|
test('modern invalid knownWords.highlightEnabled warns modern key and does not fallback to legacy', () => {
|
||||||
const { context, warnings } = makeContext({
|
const { context, warnings } = makeContext({
|
||||||
nPlusOne: { highlightEnabled: true },
|
nPlusOne: { highlightEnabled: true },
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function applyModernMediaResolution(
|
|||||||
'syncAnimatedImageToWordAudio',
|
'syncAnimatedImageToWordAudio',
|
||||||
'normalizeAudio',
|
'normalizeAudio',
|
||||||
'mirrorMpvVolume',
|
'mirrorMpvVolume',
|
||||||
|
'reviewTiming',
|
||||||
] as const) {
|
] as const) {
|
||||||
applyModernValue(
|
applyModernValue(
|
||||||
context,
|
context,
|
||||||
@@ -128,18 +129,28 @@ export function applyModernMediaResolution(
|
|||||||
'Expected non-negative number.',
|
'Expected non-negative number.',
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const key of ['fallbackDuration', 'maxMediaDuration'] as const) {
|
applyModernValue(
|
||||||
applyModernValue(
|
context,
|
||||||
context,
|
media,
|
||||||
media,
|
'fallbackDuration',
|
||||||
key,
|
'ankiConnect.media.fallbackDuration',
|
||||||
`ankiConnect.media.${key}`,
|
asPositiveNumber,
|
||||||
asPositiveNumber,
|
DEFAULT_CONFIG.ankiConnect.media.fallbackDuration,
|
||||||
DEFAULT_CONFIG.ankiConnect.media[key],
|
(value) => {
|
||||||
(value) => {
|
context.resolved.ankiConnect.media.fallbackDuration = value;
|
||||||
context.resolved.ankiConnect.media[key] = value;
|
},
|
||||||
},
|
'Expected positive number.',
|
||||||
'Expected positive number.',
|
);
|
||||||
);
|
applyModernValue(
|
||||||
}
|
context,
|
||||||
|
media,
|
||||||
|
'maxMediaDuration',
|
||||||
|
'ankiConnect.media.maxMediaDuration',
|
||||||
|
asNonNegativeNumber,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.maxMediaDuration,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.maxMediaDuration = value;
|
||||||
|
},
|
||||||
|
'Expected non-negative number.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
'mpv.aniskipEnabled': 'Enable AniSkip',
|
'mpv.aniskipEnabled': 'Enable AniSkip',
|
||||||
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
||||||
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
|
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
|
||||||
|
'ankiConnect.media.reviewTiming': 'Review Media Timing',
|
||||||
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -694,6 +695,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
|||||||
path === 'ankiConnect.ai.enabled' ||
|
path === 'ankiConnect.ai.enabled' ||
|
||||||
path === 'ankiConnect.media.normalizeAudio' ||
|
path === 'ankiConnect.media.normalizeAudio' ||
|
||||||
path === 'ankiConnect.media.mirrorMpvVolume' ||
|
path === 'ankiConnect.media.mirrorMpvVolume' ||
|
||||||
|
path === 'ankiConnect.media.reviewTiming' ||
|
||||||
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
|
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
|
||||||
path === 'ankiConnect.knownWords.highlightEnabled' ||
|
path === 'ankiConnect.knownWords.highlightEnabled' ||
|
||||||
path === 'ankiConnect.knownWords.refreshMinutes' ||
|
path === 'ankiConnect.knownWords.refreshMinutes' ||
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ import type {
|
|||||||
YoutubePickerResolveRequest,
|
YoutubePickerResolveRequest,
|
||||||
YoutubePickerResolveResult,
|
YoutubePickerResolveResult,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
import type {
|
||||||
|
MediaTimingReviewActionResult,
|
||||||
|
MediaTimingReviewPreviewRequest,
|
||||||
|
MediaTimingReviewResolveRequest,
|
||||||
|
MediaTimingReviewWaveformRequest,
|
||||||
|
MediaTimingReviewWaveformResult,
|
||||||
|
} from '../../types/anki';
|
||||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||||
import {
|
import {
|
||||||
parseMpvCommand,
|
parseMpvCommand,
|
||||||
@@ -98,6 +105,16 @@ export interface IpcServiceDeps {
|
|||||||
onYoutubePickerResolve: (
|
onYoutubePickerResolve: (
|
||||||
request: YoutubePickerResolveRequest,
|
request: YoutubePickerResolveRequest,
|
||||||
) => Promise<YoutubePickerResolveResult>;
|
) => Promise<YoutubePickerResolveResult>;
|
||||||
|
previewMediaTimingReview?: (
|
||||||
|
request: MediaTimingReviewPreviewRequest,
|
||||||
|
) => Promise<MediaTimingReviewActionResult>;
|
||||||
|
getMediaTimingReviewWaveform?: (
|
||||||
|
request: MediaTimingReviewWaveformRequest,
|
||||||
|
) => Promise<MediaTimingReviewWaveformResult>;
|
||||||
|
stopMediaTimingReviewPreview?: (reviewId: string) => Promise<MediaTimingReviewActionResult>;
|
||||||
|
resolveMediaTimingReview?: (
|
||||||
|
request: MediaTimingReviewResolveRequest,
|
||||||
|
) => MediaTimingReviewActionResult | Promise<MediaTimingReviewActionResult>;
|
||||||
getAnkiConnectStatus: () => boolean;
|
getAnkiConnectStatus: () => boolean;
|
||||||
getRuntimeOptions: () => unknown;
|
getRuntimeOptions: () => unknown;
|
||||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||||
@@ -221,6 +238,65 @@ function parseOverlayNotificationActionPayload(
|
|||||||
return { notificationId, actionId, ...(typeof noteId === 'number' ? { noteId } : {}) };
|
return { notificationId, actionId, ...(typeof noteId === 'number' ? { noteId } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMediaTimingReviewPreviewRequest(
|
||||||
|
payload: unknown,
|
||||||
|
): MediaTimingReviewPreviewRequest | null {
|
||||||
|
if (!payload || typeof payload !== 'object') return null;
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof record.reviewId !== 'string' ||
|
||||||
|
!record.reviewId ||
|
||||||
|
typeof record.startTime !== 'number' ||
|
||||||
|
!Number.isFinite(record.startTime) ||
|
||||||
|
typeof record.endTime !== 'number' ||
|
||||||
|
!Number.isFinite(record.endTime)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
reviewId: record.reviewId,
|
||||||
|
startTime: record.startTime,
|
||||||
|
endTime: record.endTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMediaTimingReviewWaveformRequest(
|
||||||
|
payload: unknown,
|
||||||
|
): MediaTimingReviewWaveformRequest | null {
|
||||||
|
return parseMediaTimingReviewPreviewRequest(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMediaTimingReviewResolveRequest(
|
||||||
|
payload: unknown,
|
||||||
|
): MediaTimingReviewResolveRequest | null {
|
||||||
|
if (!payload || typeof payload !== 'object') return null;
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
if (typeof record.reviewId !== 'string' || !record.reviewId) return null;
|
||||||
|
const decision = record.decision;
|
||||||
|
if (!decision || typeof decision !== 'object') return null;
|
||||||
|
const decisionRecord = decision as Record<string, unknown>;
|
||||||
|
if (decisionRecord.action === 'use-original' || decisionRecord.action === 'discard') {
|
||||||
|
return { reviewId: record.reviewId, decision: { action: decisionRecord.action } };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
decisionRecord.action === 'confirm' &&
|
||||||
|
typeof decisionRecord.startTime === 'number' &&
|
||||||
|
Number.isFinite(decisionRecord.startTime) &&
|
||||||
|
typeof decisionRecord.endTime === 'number' &&
|
||||||
|
Number.isFinite(decisionRecord.endTime)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
reviewId: record.reviewId,
|
||||||
|
decision: {
|
||||||
|
action: 'confirm',
|
||||||
|
startTime: decisionRecord.startTime,
|
||||||
|
endTime: decisionRecord.endTime,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IpcDepsRuntimeOptions {
|
export interface IpcDepsRuntimeOptions {
|
||||||
getMainWindow: () => WindowLike | null;
|
getMainWindow: () => WindowLike | null;
|
||||||
getVisibleOverlayVisibility: () => boolean;
|
getVisibleOverlayVisibility: () => boolean;
|
||||||
@@ -277,6 +353,10 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
onYoutubePickerResolve: (
|
onYoutubePickerResolve: (
|
||||||
request: YoutubePickerResolveRequest,
|
request: YoutubePickerResolveRequest,
|
||||||
) => Promise<YoutubePickerResolveResult>;
|
) => Promise<YoutubePickerResolveResult>;
|
||||||
|
previewMediaTimingReview?: IpcServiceDeps['previewMediaTimingReview'];
|
||||||
|
getMediaTimingReviewWaveform?: IpcServiceDeps['getMediaTimingReviewWaveform'];
|
||||||
|
stopMediaTimingReviewPreview?: IpcServiceDeps['stopMediaTimingReviewPreview'];
|
||||||
|
resolveMediaTimingReview?: IpcServiceDeps['resolveMediaTimingReview'];
|
||||||
getAnkiConnectStatus: () => boolean;
|
getAnkiConnectStatus: () => boolean;
|
||||||
getRuntimeOptions: () => unknown;
|
getRuntimeOptions: () => unknown;
|
||||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||||
@@ -370,6 +450,10 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
options.activatePlaybackWindowForOverlayInteraction ?? (() => false),
|
options.activatePlaybackWindowForOverlayInteraction ?? (() => false),
|
||||||
runSubsyncManual: options.runSubsyncManual,
|
runSubsyncManual: options.runSubsyncManual,
|
||||||
onYoutubePickerResolve: options.onYoutubePickerResolve,
|
onYoutubePickerResolve: options.onYoutubePickerResolve,
|
||||||
|
previewMediaTimingReview: options.previewMediaTimingReview,
|
||||||
|
getMediaTimingReviewWaveform: options.getMediaTimingReviewWaveform,
|
||||||
|
stopMediaTimingReviewPreview: options.stopMediaTimingReviewPreview,
|
||||||
|
resolveMediaTimingReview: options.resolveMediaTimingReview,
|
||||||
getAnkiConnectStatus: options.getAnkiConnectStatus,
|
getAnkiConnectStatus: options.getAnkiConnectStatus,
|
||||||
getRuntimeOptions: options.getRuntimeOptions,
|
getRuntimeOptions: options.getRuntimeOptions,
|
||||||
setRuntimeOption: options.setRuntimeOption,
|
setRuntimeOption: options.setRuntimeOption,
|
||||||
@@ -491,6 +575,46 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ipc.handle(
|
||||||
|
IPC_CHANNELS.request.mediaTimingReviewPreview,
|
||||||
|
async (_event: unknown, payload: unknown) => {
|
||||||
|
const request = parseMediaTimingReviewPreviewRequest(payload);
|
||||||
|
if (!request || !deps.previewMediaTimingReview) {
|
||||||
|
return { ok: false, message: 'Timing preview is unavailable.' };
|
||||||
|
}
|
||||||
|
return await deps.previewMediaTimingReview(request);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ipc.handle(
|
||||||
|
IPC_CHANNELS.request.mediaTimingReviewWaveform,
|
||||||
|
async (_event: unknown, payload: unknown) => {
|
||||||
|
const request = parseMediaTimingReviewWaveformRequest(payload);
|
||||||
|
if (!request || !deps.getMediaTimingReviewWaveform) {
|
||||||
|
return { ok: false, message: 'Timing waveform is unavailable.' };
|
||||||
|
}
|
||||||
|
return await deps.getMediaTimingReviewWaveform(request);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ipc.handle(
|
||||||
|
IPC_CHANNELS.request.mediaTimingReviewStopPreview,
|
||||||
|
async (_event: unknown, reviewId: unknown) => {
|
||||||
|
if (typeof reviewId !== 'string' || !reviewId || !deps.stopMediaTimingReviewPreview) {
|
||||||
|
return { ok: false, message: 'Timing preview is unavailable.' };
|
||||||
|
}
|
||||||
|
return await deps.stopMediaTimingReviewPreview(reviewId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ipc.handle(
|
||||||
|
IPC_CHANNELS.request.mediaTimingReviewResolve,
|
||||||
|
async (_event: unknown, payload: unknown) => {
|
||||||
|
const request = parseMediaTimingReviewResolveRequest(payload);
|
||||||
|
if (!request || !deps.resolveMediaTimingReview) {
|
||||||
|
return { ok: false, message: 'Timing review is unavailable.' };
|
||||||
|
}
|
||||||
|
return await deps.resolveMediaTimingReview(request);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
|
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
|
||||||
deps.openYomitanSettings();
|
deps.openYomitanSettings();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import net from 'node:net';
|
||||||
|
import { describe, test } from 'node:test';
|
||||||
|
import { buildMediaTimingPreviewArgs, MediaTimingPreviewSession } from './media-timing-preview';
|
||||||
|
|
||||||
|
describe('buildMediaTimingPreviewArgs', () => {
|
||||||
|
test('creates a hidden audio-only reusable mpv session', () => {
|
||||||
|
const args = buildMediaTimingPreviewArgs('/tmp/review.sock', {
|
||||||
|
mediaPath: '/video/show.mkv',
|
||||||
|
audioTrackId: 3,
|
||||||
|
volume: 55,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(args.includes('--no-video'));
|
||||||
|
assert.ok(args.includes('--force-window=no'));
|
||||||
|
assert.ok(args.includes('--idle=yes'));
|
||||||
|
assert.ok(args.includes('--pause=yes'));
|
||||||
|
assert.ok(args.includes('--input-ipc-server=/tmp/review.sock'));
|
||||||
|
assert.ok(args.includes('--aid=3'));
|
||||||
|
assert.ok(args.includes('--volume=55'));
|
||||||
|
assert.equal(args.at(-2), '--');
|
||||||
|
assert.equal(args.at(-1), '/video/show.mkv');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('separates an option-like media path without adding optional audio arguments', () => {
|
||||||
|
const args = buildMediaTimingPreviewArgs('/tmp/review.sock', {
|
||||||
|
mediaPath: '--fullscreen',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(args.at(-2), '--');
|
||||||
|
assert.equal(args.at(-1), '--fullscreen');
|
||||||
|
assert.equal(
|
||||||
|
args.some((arg) => arg.startsWith('--aid=')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
args.some((arg) => arg.startsWith('--volume=')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preview session handles socket errors after connecting', async () => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
||||||
|
child.kill = () => true;
|
||||||
|
const session = new MediaTimingPreviewSession({
|
||||||
|
platform: 'linux',
|
||||||
|
spawnProcess: () => child as never,
|
||||||
|
connectSocket: () => {
|
||||||
|
queueMicrotask(() => socket.emit('connect'));
|
||||||
|
return socket;
|
||||||
|
},
|
||||||
|
removeSocketFile: () => undefined,
|
||||||
|
createSocketPath: () => '/tmp/review.sock',
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.start({ mediaPath: '/video/show.mkv' });
|
||||||
|
assert.doesNotThrow(() => socket.emit('error', new Error('pipe closed')));
|
||||||
|
await assert.rejects(session.play(1, 2), /not ready/);
|
||||||
|
session.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preview session keeps failed connection errors handled through destruction', async () => {
|
||||||
|
const socket = new EventEmitter() as EventEmitter & {
|
||||||
|
destroy: () => void;
|
||||||
|
};
|
||||||
|
socket.destroy = () => {
|
||||||
|
socket.emit('error', new Error('socket failed again while closing'));
|
||||||
|
};
|
||||||
|
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
||||||
|
child.kill = () => true;
|
||||||
|
const times = [0, 0, 0, 6_000];
|
||||||
|
const session = new MediaTimingPreviewSession({
|
||||||
|
platform: 'linux',
|
||||||
|
spawnProcess: () => child as never,
|
||||||
|
connectSocket: () => {
|
||||||
|
queueMicrotask(() => socket.emit('error', new Error('connection failed')));
|
||||||
|
return socket as never;
|
||||||
|
},
|
||||||
|
now: () => times.shift() ?? 6_000,
|
||||||
|
removeSocketFile: () => undefined,
|
||||||
|
createSocketPath: () => '/tmp/review.sock',
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /Timed out starting/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preview session rejects a connection that finishes after disposal', async () => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
||||||
|
child.kill = () => true;
|
||||||
|
const session = new MediaTimingPreviewSession({
|
||||||
|
platform: 'linux',
|
||||||
|
spawnProcess: () => child as never,
|
||||||
|
connectSocket: () => socket,
|
||||||
|
removeSocketFile: () => undefined,
|
||||||
|
createSocketPath: () => '/tmp/review.sock',
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingStart = session.start({ mediaPath: '-playlist' });
|
||||||
|
session.dispose();
|
||||||
|
socket.emit('connect');
|
||||||
|
|
||||||
|
await assert.rejects(pendingStart, /closed/);
|
||||||
|
assert.equal(socket.destroyed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preview session shares one startup across concurrent start calls', async () => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
||||||
|
child.kill = () => true;
|
||||||
|
let spawnCount = 0;
|
||||||
|
const session = new MediaTimingPreviewSession({
|
||||||
|
platform: 'linux',
|
||||||
|
spawnProcess: () => {
|
||||||
|
spawnCount += 1;
|
||||||
|
return child as never;
|
||||||
|
},
|
||||||
|
connectSocket: () => socket,
|
||||||
|
removeSocketFile: () => undefined,
|
||||||
|
createSocketPath: () => '/tmp/review.sock',
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstStart = session.start({ mediaPath: '/video/show.mkv' });
|
||||||
|
const secondStart = session.start({ mediaPath: '/video/show.mkv' });
|
||||||
|
socket.emit('connect');
|
||||||
|
|
||||||
|
await Promise.all([firstStart, secondStart]);
|
||||||
|
assert.equal(spawnCount, 1);
|
||||||
|
session.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preview session can start again after a startup failure', async () => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
||||||
|
child.kill = () => true;
|
||||||
|
let spawnCount = 0;
|
||||||
|
const session = new MediaTimingPreviewSession({
|
||||||
|
platform: 'linux',
|
||||||
|
spawnProcess: () => {
|
||||||
|
spawnCount += 1;
|
||||||
|
if (spawnCount === 1) throw new Error('spawn failed');
|
||||||
|
return child as never;
|
||||||
|
},
|
||||||
|
connectSocket: () => {
|
||||||
|
queueMicrotask(() => socket.emit('connect'));
|
||||||
|
return socket;
|
||||||
|
},
|
||||||
|
removeSocketFile: () => undefined,
|
||||||
|
createSocketPath: () => '/tmp/review.sock',
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /spawn failed/);
|
||||||
|
await session.start({ mediaPath: '/video/show.mkv' });
|
||||||
|
assert.equal(spawnCount, 2);
|
||||||
|
session.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preview session bounds a connection attempt that never settles', async () => {
|
||||||
|
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
||||||
|
child.kill = () => true;
|
||||||
|
let nowMs = 0;
|
||||||
|
let connectAttempts = 0;
|
||||||
|
const session = new MediaTimingPreviewSession({
|
||||||
|
platform: 'linux',
|
||||||
|
spawnProcess: () => child as never,
|
||||||
|
connectSocket: () => {
|
||||||
|
connectAttempts += 1;
|
||||||
|
const socket = new net.Socket();
|
||||||
|
socket.destroy = (() => {
|
||||||
|
socket.emit('error', new Error('socket failed while timing out'));
|
||||||
|
return socket;
|
||||||
|
}) as typeof socket.destroy;
|
||||||
|
return socket;
|
||||||
|
},
|
||||||
|
now: () => {
|
||||||
|
const current = nowMs;
|
||||||
|
nowMs += 1_000;
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
schedule: (callback) => setTimeout(callback, 0),
|
||||||
|
cancelSchedule: (timeout) => clearTimeout(timeout),
|
||||||
|
removeSocketFile: () => undefined,
|
||||||
|
createSocketPath: () => '/tmp/review.sock',
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /Timed out starting/);
|
||||||
|
assert.equal(connectAttempts, 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { spawn, type ChildProcess } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import net, { type Socket } from 'net';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
const CONNECT_TIMEOUT_MS = 5_000;
|
||||||
|
const CONNECT_ATTEMPT_TIMEOUT_MS = 500;
|
||||||
|
const CONNECT_RETRY_MS = 40;
|
||||||
|
|
||||||
|
export interface MediaTimingPreviewStartOptions {
|
||||||
|
mediaPath: string;
|
||||||
|
executablePath?: string;
|
||||||
|
audioTrackId?: number;
|
||||||
|
volume?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreviewProcess = Pick<ChildProcess, 'kill' | 'once'>;
|
||||||
|
|
||||||
|
interface MediaTimingPreviewDeps {
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
spawnProcess: (command: string, args: string[]) => PreviewProcess;
|
||||||
|
connectSocket: (socketPath: string) => Socket;
|
||||||
|
now: () => number;
|
||||||
|
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||||
|
cancelSchedule: (timeout: ReturnType<typeof setTimeout>) => void;
|
||||||
|
removeSocketFile: (socketPath: string) => void;
|
||||||
|
createSocketPath: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMediaTimingPreviewArgs(
|
||||||
|
socketPath: string,
|
||||||
|
options: MediaTimingPreviewStartOptions,
|
||||||
|
): string[] {
|
||||||
|
const args = [
|
||||||
|
'--no-config',
|
||||||
|
'--no-video',
|
||||||
|
'--audio-display=no',
|
||||||
|
'--force-window=no',
|
||||||
|
'--idle=yes',
|
||||||
|
'--keep-open=yes',
|
||||||
|
'--pause=yes',
|
||||||
|
'--terminal=no',
|
||||||
|
'--msg-level=all=warn',
|
||||||
|
`--input-ipc-server=${socketPath}`,
|
||||||
|
];
|
||||||
|
if (typeof options.audioTrackId === 'number' && Number.isInteger(options.audioTrackId)) {
|
||||||
|
args.push(`--aid=${options.audioTrackId}`);
|
||||||
|
}
|
||||||
|
if (typeof options.volume === 'number' && Number.isFinite(options.volume)) {
|
||||||
|
args.push(`--volume=${Math.max(0, options.volume)}`);
|
||||||
|
}
|
||||||
|
args.push('--', options.mediaPath);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultSocketPath(): string {
|
||||||
|
const suffix = `${process.pid}-${randomUUID()}`;
|
||||||
|
return process.platform === 'win32'
|
||||||
|
? `\\\\.\\pipe\\subminer-timing-preview-${suffix}`
|
||||||
|
: path.join(os.tmpdir(), `subminer-timing-preview-${suffix}.sock`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePosixSocketFile(socketPath: string): void {
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(socketPath);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MediaTimingPreviewSession {
|
||||||
|
private readonly deps: MediaTimingPreviewDeps;
|
||||||
|
private socketPath: string | null = null;
|
||||||
|
private socket: Socket | null = null;
|
||||||
|
private process: PreviewProcess | null = null;
|
||||||
|
private startupError: Error | null = null;
|
||||||
|
private startPromise: Promise<void> | null = null;
|
||||||
|
private retryWait: {
|
||||||
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
|
resolve: () => void;
|
||||||
|
} | null = null;
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
|
constructor(deps: Partial<MediaTimingPreviewDeps> = {}) {
|
||||||
|
this.deps = {
|
||||||
|
platform: process.platform,
|
||||||
|
spawnProcess: (command, args) => spawn(command, args, { stdio: 'ignore' }),
|
||||||
|
connectSocket: (socketPath) => net.createConnection(socketPath),
|
||||||
|
now: Date.now,
|
||||||
|
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||||
|
cancelSchedule: (timeout) => clearTimeout(timeout),
|
||||||
|
removeSocketFile: removePosixSocketFile,
|
||||||
|
createSocketPath: createDefaultSocketPath,
|
||||||
|
...deps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(options: MediaTimingPreviewStartOptions): Promise<void> {
|
||||||
|
if (this.disposed) throw new Error('Preview session is closed');
|
||||||
|
if (this.socket) return;
|
||||||
|
if (this.startPromise) return await this.startPromise;
|
||||||
|
|
||||||
|
const startPromise = this.startOnce(options);
|
||||||
|
this.startPromise = startPromise;
|
||||||
|
try {
|
||||||
|
await startPromise;
|
||||||
|
} catch (error) {
|
||||||
|
this.releaseResources();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (this.startPromise === startPromise) this.startPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startOnce(options: MediaTimingPreviewStartOptions): Promise<void> {
|
||||||
|
const mediaPath = options.mediaPath.trim();
|
||||||
|
if (!mediaPath) throw new Error('No media source is available for preview');
|
||||||
|
|
||||||
|
const socketPath = this.deps.createSocketPath();
|
||||||
|
this.socketPath = socketPath;
|
||||||
|
if (this.deps.platform !== 'win32') {
|
||||||
|
this.deps.removeSocketFile(socketPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = options.executablePath?.trim() || 'mpv';
|
||||||
|
this.startupError = null;
|
||||||
|
const child = this.deps.spawnProcess(
|
||||||
|
command,
|
||||||
|
buildMediaTimingPreviewArgs(socketPath, { ...options, mediaPath }),
|
||||||
|
);
|
||||||
|
this.process = child;
|
||||||
|
child.once('error', (error) => {
|
||||||
|
if (this.process !== child) return;
|
||||||
|
this.startupError = error;
|
||||||
|
});
|
||||||
|
child.once('exit', () => {
|
||||||
|
if (this.process !== child) return;
|
||||||
|
if (!this.socket && !this.disposed && !this.startupError) {
|
||||||
|
this.startupError = new Error('The hidden mpv preview player exited during startup');
|
||||||
|
}
|
||||||
|
this.socket?.destroy();
|
||||||
|
this.socket = null;
|
||||||
|
this.process = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.connectWithRetry(socketPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async play(startTime: number, endTime: number): Promise<void> {
|
||||||
|
if (!this.socket || this.socket.destroyed) {
|
||||||
|
throw new Error('Preview player is not ready');
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) {
|
||||||
|
throw new Error('Preview timing is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.send(['set_property', 'pause', true]);
|
||||||
|
this.send(['set_property', 'ab-loop-a', startTime]);
|
||||||
|
this.send(['set_property', 'ab-loop-b', endTime]);
|
||||||
|
this.send(['seek', startTime, 'absolute+exact']);
|
||||||
|
this.send(['set_property', 'pause', false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!this.socket || this.socket.destroyed) return;
|
||||||
|
this.send(['set_property', 'pause', true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.disposed = true;
|
||||||
|
this.releaseResources();
|
||||||
|
}
|
||||||
|
|
||||||
|
private releaseResources(): void {
|
||||||
|
this.cancelRetryWait();
|
||||||
|
try {
|
||||||
|
this.send(['quit']);
|
||||||
|
} catch {
|
||||||
|
// The process may already have exited.
|
||||||
|
}
|
||||||
|
this.socket?.end();
|
||||||
|
this.socket?.destroy();
|
||||||
|
this.socket = null;
|
||||||
|
const child = this.process;
|
||||||
|
this.process = null;
|
||||||
|
child?.kill();
|
||||||
|
if (this.socketPath && this.deps.platform !== 'win32') {
|
||||||
|
try {
|
||||||
|
this.deps.removeSocketFile(this.socketPath);
|
||||||
|
} catch {
|
||||||
|
// mpv may still be releasing the socket. The OS temp directory owns cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.socketPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(command: Array<string | number | boolean>): void {
|
||||||
|
if (!this.socket || this.socket.destroyed) {
|
||||||
|
throw new Error('Preview player is not connected');
|
||||||
|
}
|
||||||
|
this.socket.write(`${JSON.stringify({ command })}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connectWithRetry(socketPath: string): Promise<void> {
|
||||||
|
const deadline = this.deps.now() + CONNECT_TIMEOUT_MS;
|
||||||
|
while (!this.disposed && this.deps.now() < deadline) {
|
||||||
|
if (this.startupError) {
|
||||||
|
throw this.startupError;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const remainingMs = deadline - this.deps.now();
|
||||||
|
if (remainingMs <= 0) break;
|
||||||
|
const socket = await this.connectOnce(
|
||||||
|
socketPath,
|
||||||
|
Math.min(CONNECT_ATTEMPT_TIMEOUT_MS, remainingMs),
|
||||||
|
);
|
||||||
|
if (this.disposed) {
|
||||||
|
socket.destroy();
|
||||||
|
throw new Error('Preview session is closed');
|
||||||
|
}
|
||||||
|
this.socket = socket;
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
if (this.disposed) {
|
||||||
|
throw new Error('Preview session is closed');
|
||||||
|
}
|
||||||
|
const remainingMs = deadline - this.deps.now();
|
||||||
|
if (remainingMs <= 0) break;
|
||||||
|
await this.waitForRetry(Math.min(CONNECT_RETRY_MS, remainingMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.startupError) {
|
||||||
|
throw this.startupError;
|
||||||
|
}
|
||||||
|
if (this.disposed) {
|
||||||
|
throw new Error('Preview session is closed');
|
||||||
|
}
|
||||||
|
throw new Error('Timed out starting the hidden mpv preview player');
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitForRetry(delayMs: number): Promise<void> {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const timeout = this.deps.schedule(() => {
|
||||||
|
if (this.retryWait?.timeout === timeout) this.retryWait = null;
|
||||||
|
resolve();
|
||||||
|
}, delayMs);
|
||||||
|
this.retryWait = { timeout, resolve };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelRetryWait(): void {
|
||||||
|
const pending = this.retryWait;
|
||||||
|
this.retryWait = null;
|
||||||
|
if (!pending) return;
|
||||||
|
this.deps.cancelSchedule(pending.timeout);
|
||||||
|
pending.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
private connectOnce(socketPath: string, timeoutMs: number): Promise<Socket> {
|
||||||
|
return new Promise<Socket>((resolve, reject) => {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let settled = false;
|
||||||
|
const clearAttemptTimeout = (): void => {
|
||||||
|
if (timeout !== null) this.deps.cancelSchedule(timeout);
|
||||||
|
timeout = null;
|
||||||
|
};
|
||||||
|
const socket = this.deps.connectSocket(socketPath);
|
||||||
|
const onConnect = (): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearAttemptTimeout();
|
||||||
|
socket.off('error', onError);
|
||||||
|
socket.on('error', () => {
|
||||||
|
socket.destroy();
|
||||||
|
if (this.socket === socket) this.socket = null;
|
||||||
|
});
|
||||||
|
socket.once('close', () => {
|
||||||
|
if (this.socket === socket) this.socket = null;
|
||||||
|
});
|
||||||
|
resolve(socket);
|
||||||
|
};
|
||||||
|
const onError = (error: Error): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearAttemptTimeout();
|
||||||
|
socket.off('connect', onConnect);
|
||||||
|
socket.on('error', () => {});
|
||||||
|
socket.destroy();
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
socket.once('connect', onConnect);
|
||||||
|
socket.once('error', onError);
|
||||||
|
timeout = this.deps.schedule(() => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
timeout = null;
|
||||||
|
socket.off('connect', onConnect);
|
||||||
|
socket.off('error', onError);
|
||||||
|
socket.on('error', () => {});
|
||||||
|
socket.destroy();
|
||||||
|
reject(new Error('Timed out connecting to the hidden mpv preview player'));
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+29
@@ -462,6 +462,10 @@ import { createMainBootServices, type MainBootServicesResult } from './main/boot
|
|||||||
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
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 { generateSpeechWaveform } from './core/services/media-timing-waveform';
|
||||||
|
import { createMediaTimingReviewRuntime } from './main/runtime/media-timing-review';
|
||||||
|
import { openMediaTimingReviewModal } from './main/runtime/media-timing-review-open';
|
||||||
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
||||||
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
|
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
|
||||||
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
|
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
|
||||||
@@ -2797,6 +2801,19 @@ function createOverlayHostedModalOpenDeps(): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
||||||
|
getMpvClient: () => appState.mpvClient,
|
||||||
|
getCurrentMediaPath: () =>
|
||||||
|
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
|
||||||
|
getMpvExecutablePath: () =>
|
||||||
|
configService.getConfig().mpv.executablePath || process.env.SUBMINER_MPV_PATH?.trim() || '',
|
||||||
|
createPreviewSession: () => new MediaTimingPreviewSession(),
|
||||||
|
generateWaveform: (options) => generateSpeechWaveform(options),
|
||||||
|
openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload),
|
||||||
|
showStatus: (message) =>
|
||||||
|
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }),
|
||||||
|
});
|
||||||
|
|
||||||
function openOverlayHostedModalWithOsd(
|
function openOverlayHostedModalWithOsd(
|
||||||
openModal: (deps: ReturnType<typeof createOverlayHostedModalOpenDeps>) => Promise<boolean>,
|
openModal: (deps: ReturnType<typeof createOverlayHostedModalOpenDeps>) => Promise<boolean>,
|
||||||
unavailableMessage: string,
|
unavailableMessage: string,
|
||||||
@@ -5014,6 +5031,7 @@ function initializeOverlayRuntime(): void {
|
|||||||
refreshCurrentSubtitleAfterKnownWordUpdate,
|
refreshCurrentSubtitleAfterKnownWordUpdate,
|
||||||
);
|
);
|
||||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
||||||
|
appState.ankiIntegration?.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview);
|
||||||
syncOverlayMpvSubtitleSuppression();
|
syncOverlayMpvSubtitleSuppression();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5423,6 +5441,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text),
|
showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||||
},
|
},
|
||||||
mainDeps: {
|
mainDeps: {
|
||||||
|
previewMediaTimingReview: (request) => mediaTimingReviewRuntime.previewRange(request),
|
||||||
|
getMediaTimingReviewWaveform: (request) => mediaTimingReviewRuntime.getWaveform(request),
|
||||||
|
stopMediaTimingReviewPreview: (reviewId) => mediaTimingReviewRuntime.stopPreview(reviewId),
|
||||||
|
resolveMediaTimingReview: (request) => mediaTimingReviewRuntime.resolveReview(request),
|
||||||
getMainWindow: () => overlayManager.getMainWindow(),
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(),
|
getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
focusMainWindow: () => {
|
focusMainWindow: () => {
|
||||||
@@ -5456,6 +5478,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOverlayModalClosed: (modal, senderWindow) => {
|
onOverlayModalClosed: (modal, senderWindow) => {
|
||||||
|
if (modal === 'media-timing-review') {
|
||||||
|
void mediaTimingReviewRuntime.dispose();
|
||||||
|
}
|
||||||
if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) {
|
if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) {
|
||||||
subtitleSidebarRequestedOpen = false;
|
subtitleSidebarRequestedOpen = false;
|
||||||
}
|
}
|
||||||
@@ -5811,6 +5836,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
|
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
|
||||||
consumePendingSubtitleMiningContext,
|
consumePendingSubtitleMiningContext,
|
||||||
);
|
);
|
||||||
|
appState.ankiIntegration?.setMediaTimingReviewCallback(
|
||||||
|
mediaTimingReviewRuntime.requestReview,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||||
getCachedMediaPath: (currentVideoPath, kind) =>
|
getCachedMediaPath: (currentVideoPath, kind) =>
|
||||||
@@ -6133,6 +6161,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
|||||||
if (overlayManager.getModalWindow() !== window) {
|
if (overlayManager.getModalWindow() !== window) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
void mediaTimingReviewRuntime.dispose();
|
||||||
overlayManager.setModalWindow(null);
|
overlayManager.setModalWindow(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint'];
|
onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint'];
|
||||||
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
|
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
|
||||||
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
||||||
|
previewMediaTimingReview?: IpcDepsRuntimeOptions['previewMediaTimingReview'];
|
||||||
|
getMediaTimingReviewWaveform?: IpcDepsRuntimeOptions['getMediaTimingReviewWaveform'];
|
||||||
|
stopMediaTimingReviewPreview?: IpcDepsRuntimeOptions['stopMediaTimingReviewPreview'];
|
||||||
|
resolveMediaTimingReview?: IpcDepsRuntimeOptions['resolveMediaTimingReview'];
|
||||||
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
||||||
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
||||||
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
|
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
|
||||||
@@ -256,6 +260,10 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
onOverlayInteractiveHint: params.onOverlayInteractiveHint,
|
onOverlayInteractiveHint: params.onOverlayInteractiveHint,
|
||||||
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
|
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
|
||||||
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
||||||
|
previewMediaTimingReview: params.previewMediaTimingReview,
|
||||||
|
getMediaTimingReviewWaveform: params.getMediaTimingReviewWaveform,
|
||||||
|
stopMediaTimingReviewPreview: params.stopMediaTimingReviewPreview,
|
||||||
|
resolveMediaTimingReview: params.resolveMediaTimingReview,
|
||||||
openYomitanSettings: params.openYomitanSettings,
|
openYomitanSettings: params.openYomitanSettings,
|
||||||
quitApp: params.quitApp,
|
quitApp: params.quitApp,
|
||||||
toggleVisibleOverlay: params.toggleVisibleOverlay,
|
toggleVisibleOverlay: params.toggleVisibleOverlay,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||||
|
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
|
||||||
|
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||||
|
|
||||||
|
const MODAL: OverlayHostedModal = 'media-timing-review';
|
||||||
|
|
||||||
|
export async function openMediaTimingReviewModal(
|
||||||
|
deps: {
|
||||||
|
ensureOverlayStartupPrereqs: () => void;
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||||
|
sendToActiveOverlayWindow: (
|
||||||
|
channel: string,
|
||||||
|
payload?: unknown,
|
||||||
|
runtimeOptions?: {
|
||||||
|
restoreOnModalClose?: OverlayHostedModal;
|
||||||
|
preferModalWindow?: boolean;
|
||||||
|
},
|
||||||
|
) => boolean;
|
||||||
|
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||||
|
logWarn: (message: string) => void;
|
||||||
|
},
|
||||||
|
payload: MediaTimingReviewOpenPayload,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return await retryOverlayModalOpen(
|
||||||
|
{ waitForModalOpen: deps.waitForModalOpen, logWarn: deps.logWarn },
|
||||||
|
{
|
||||||
|
modal: MODAL,
|
||||||
|
timeoutMs: 1_500,
|
||||||
|
retryWarning:
|
||||||
|
'Media timing review did not acknowledge modal open; retrying the dedicated modal window.',
|
||||||
|
sendOpen: () =>
|
||||||
|
openOverlayHostedModal(deps, {
|
||||||
|
channel: IPC_CHANNELS.event.mediaTimingReviewOpen,
|
||||||
|
modal: MODAL,
|
||||||
|
payload,
|
||||||
|
preferModalWindow: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from 'node:test';
|
||||||
|
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
|
||||||
|
import {
|
||||||
|
buildMediaTimingReviewPayload,
|
||||||
|
createMediaTimingReviewRuntime,
|
||||||
|
} from './media-timing-review';
|
||||||
|
|
||||||
|
describe('buildMediaTimingReviewPayload', () => {
|
||||||
|
test('starts from the padded range and leaves two seconds to drag on each side', () => {
|
||||||
|
const payload = buildMediaTimingReviewPayload(
|
||||||
|
{
|
||||||
|
kind: 'sentence',
|
||||||
|
text: '字幕',
|
||||||
|
startTime: 10,
|
||||||
|
endTime: 12,
|
||||||
|
audioPadding: 0.5,
|
||||||
|
maxMediaDuration: 30,
|
||||||
|
},
|
||||||
|
{ reviewId: 'review-1', mediaDuration: 100 },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(payload.selectionStartTime, 9.5);
|
||||||
|
assert.equal(payload.selectionEndTime, 12.5);
|
||||||
|
assert.equal(payload.timelineStartTime, 7.5);
|
||||||
|
assert.equal(payload.timelineEndTime, 14.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamps the padded selection and timeline to media bounds', () => {
|
||||||
|
const payload = buildMediaTimingReviewPayload(
|
||||||
|
{
|
||||||
|
kind: 'word',
|
||||||
|
text: '字幕',
|
||||||
|
startTime: 0.2,
|
||||||
|
endTime: 9.8,
|
||||||
|
audioPadding: 1,
|
||||||
|
maxMediaDuration: 30,
|
||||||
|
},
|
||||||
|
{ reviewId: 'review-2', mediaDuration: 10 },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(payload.selectionStartTime, 0);
|
||||||
|
assert.equal(payload.selectionEndTime, 10);
|
||||||
|
assert.equal(payload.timelineStartTime, 0);
|
||||||
|
assert.equal(payload.timelineEndTime, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps an uncapped selection when max media duration is disabled', () => {
|
||||||
|
const payload = buildMediaTimingReviewPayload(
|
||||||
|
{
|
||||||
|
kind: 'sentence',
|
||||||
|
text: '字幕',
|
||||||
|
startTime: 10,
|
||||||
|
endTime: 55,
|
||||||
|
audioPadding: 1,
|
||||||
|
maxMediaDuration: 0,
|
||||||
|
},
|
||||||
|
{ reviewId: 'review-unlimited', mediaDuration: 100 },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(payload.selectionStartTime, 9);
|
||||||
|
assert.equal(payload.selectionEndTime, 56);
|
||||||
|
assert.equal(payload.maxMediaDuration, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function startActiveMediaTimingReview(
|
||||||
|
options: {
|
||||||
|
maxMediaDuration?: number;
|
||||||
|
decisionTimeoutMs?: number;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const previewCalls: Array<[number, number]> = [];
|
||||||
|
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
|
||||||
|
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
|
||||||
|
publishPayload = resolve;
|
||||||
|
});
|
||||||
|
const runtime = createMediaTimingReviewRuntime({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentVideoPath: '/video/show.mkv',
|
||||||
|
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
|
||||||
|
send: () => undefined,
|
||||||
|
}),
|
||||||
|
getCurrentMediaPath: () => '/video/show.mkv',
|
||||||
|
getMpvExecutablePath: () => 'mpv',
|
||||||
|
generateWaveform: async () => [],
|
||||||
|
decisionTimeoutMs: options.decisionTimeoutMs,
|
||||||
|
createPreviewSession: () => ({
|
||||||
|
start: async () => undefined,
|
||||||
|
play: async (startTime, endTime) => {
|
||||||
|
previewCalls.push([startTime, endTime]);
|
||||||
|
},
|
||||||
|
stop: async () => undefined,
|
||||||
|
dispose: () => undefined,
|
||||||
|
}),
|
||||||
|
openModal: async (payload) => {
|
||||||
|
publishPayload(payload);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
showStatus: () => undefined,
|
||||||
|
});
|
||||||
|
const pendingDecision = runtime.requestReview({
|
||||||
|
kind: 'sentence',
|
||||||
|
text: '字幕',
|
||||||
|
startTime: 10,
|
||||||
|
endTime: 12,
|
||||||
|
audioPadding: 0,
|
||||||
|
maxMediaDuration: options.maxMediaDuration ?? 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { runtime, payload: await openedPayload, pendingDecision, previewCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('media timing review pauses playback, resolves exact timing, and restores playing state', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const previewCalls: Array<[number, number]> = [];
|
||||||
|
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
|
||||||
|
runtime = createMediaTimingReviewRuntime({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentVideoPath: '/video/show.mkv',
|
||||||
|
requestProperty: async (name) =>
|
||||||
|
({ pause: false, duration: 100, aid: 2, volume: 60 })[
|
||||||
|
name as 'pause' | 'duration' | 'aid' | 'volume'
|
||||||
|
],
|
||||||
|
send: ({ command }) => commands.push(command),
|
||||||
|
}),
|
||||||
|
getCurrentMediaPath: () => '/video/show.mkv',
|
||||||
|
getMpvExecutablePath: () => 'mpv',
|
||||||
|
generateWaveform: async () => [],
|
||||||
|
createPreviewSession: () => ({
|
||||||
|
start: async () => undefined,
|
||||||
|
play: async (startTime, endTime) => {
|
||||||
|
previewCalls.push([startTime, endTime]);
|
||||||
|
},
|
||||||
|
stop: async () => undefined,
|
||||||
|
dispose: () => undefined,
|
||||||
|
}),
|
||||||
|
openModal: async (payload) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void runtime
|
||||||
|
.previewRange({
|
||||||
|
reviewId: payload.reviewId,
|
||||||
|
startTime: 9.5,
|
||||||
|
endTime: 12.5,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
runtime.resolveReview({
|
||||||
|
reviewId: payload.reviewId,
|
||||||
|
decision: { action: 'confirm', startTime: 9.5, endTime: 12.5 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
showStatus: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const decision = await runtime.requestReview({
|
||||||
|
kind: 'word',
|
||||||
|
text: '字幕',
|
||||||
|
startTime: 10,
|
||||||
|
endTime: 12,
|
||||||
|
noteId: 42,
|
||||||
|
audioPadding: 0.5,
|
||||||
|
maxMediaDuration: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(decision, { action: 'confirm', startTime: 9.5, endTime: 12.5 });
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['set_property', 'pause', 'no'],
|
||||||
|
]);
|
||||||
|
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 rejects stale and out-of-range actions before allowing discard', async () => {
|
||||||
|
const { runtime, payload, pendingDecision, previewCalls } = await startActiveMediaTimingReview({
|
||||||
|
maxMediaDuration: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
await runtime.previewRange({ reviewId: 'stale-review', startTime: 10, endTime: 12 }),
|
||||||
|
{ ok: false, message: 'This timing review is no longer active.' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
runtime.resolveReview({
|
||||||
|
reviewId: 'stale-review',
|
||||||
|
decision: { action: 'confirm', startTime: 10, endTime: 12 },
|
||||||
|
}),
|
||||||
|
{ ok: false, message: 'This timing review is no longer active.' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
runtime.resolveReview({
|
||||||
|
reviewId: payload.reviewId,
|
||||||
|
decision: { action: 'confirm', startTime: 10, endTime: 14 },
|
||||||
|
}),
|
||||||
|
{ ok: false, message: 'The selected timing range is invalid.' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
runtime.resolveReview({
|
||||||
|
reviewId: payload.reviewId,
|
||||||
|
decision: { action: 'confirm', startTime: 99, endTime: 100.5 },
|
||||||
|
}),
|
||||||
|
{ ok: false, message: 'The selected timing range is invalid.' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'discard' } }),
|
||||||
|
{ ok: true },
|
||||||
|
);
|
||||||
|
assert.deepEqual(await pendingDecision, { action: 'discard' });
|
||||||
|
assert.deepEqual(previewCalls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('media timing review watchdog falls back when the renderer stops responding', async () => {
|
||||||
|
const { pendingDecision } = await startActiveMediaTimingReview({ decisionTimeoutMs: 0 });
|
||||||
|
|
||||||
|
assert.deepEqual(await pendingDecision, { action: 'use-original' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('media timing review does not resume playback when the prior state is unavailable', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
|
||||||
|
runtime = createMediaTimingReviewRuntime({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentVideoPath: '/video/show.mkv',
|
||||||
|
requestProperty: async () => null,
|
||||||
|
send: ({ command }) => commands.push(command),
|
||||||
|
}),
|
||||||
|
getCurrentMediaPath: () => '/video/show.mkv',
|
||||||
|
getMpvExecutablePath: () => '',
|
||||||
|
generateWaveform: async () => [],
|
||||||
|
createPreviewSession: () => ({
|
||||||
|
start: async () => {
|
||||||
|
throw new Error('preview unavailable');
|
||||||
|
},
|
||||||
|
play: async () => undefined,
|
||||||
|
stop: async () => undefined,
|
||||||
|
dispose: () => undefined,
|
||||||
|
}),
|
||||||
|
openModal: async (payload) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
runtime.resolveReview({
|
||||||
|
reviewId: payload.reviewId,
|
||||||
|
decision: { action: 'use-original' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
showStatus: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
await runtime.requestReview({
|
||||||
|
kind: 'sentence',
|
||||||
|
text: '字幕',
|
||||||
|
startTime: 10,
|
||||||
|
endTime: 12,
|
||||||
|
audioPadding: 0,
|
||||||
|
maxMediaDuration: 30,
|
||||||
|
}),
|
||||||
|
{ action: 'use-original' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('media timing review restores playback when setup fails after pausing', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const runtime = createMediaTimingReviewRuntime({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentVideoPath: '/video/show.mkv',
|
||||||
|
requestProperty: async (name) => (name === 'pause' ? false : null),
|
||||||
|
send: ({ command }) => commands.push(command),
|
||||||
|
}),
|
||||||
|
getCurrentMediaPath: () => '/video/show.mkv',
|
||||||
|
getMpvExecutablePath: () => 'mpv',
|
||||||
|
generateWaveform: async () => [],
|
||||||
|
createPreviewSession: () => {
|
||||||
|
throw new Error('preview setup failed');
|
||||||
|
},
|
||||||
|
openModal: async () => true,
|
||||||
|
showStatus: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
await runtime.requestReview({
|
||||||
|
kind: 'word',
|
||||||
|
text: '字幕',
|
||||||
|
startTime: 10,
|
||||||
|
endTime: 12,
|
||||||
|
audioPadding: 0,
|
||||||
|
maxMediaDuration: 30,
|
||||||
|
}),
|
||||||
|
{ action: 'use-original' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['set_property', 'pause', 'no'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disposing an open review settles it with original timing and restores playback', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const runtime = createMediaTimingReviewRuntime({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentVideoPath: '/video/show.mkv',
|
||||||
|
requestProperty: async (name) => (name === 'pause' ? false : null),
|
||||||
|
send: ({ command }) => commands.push(command),
|
||||||
|
}),
|
||||||
|
getCurrentMediaPath: () => '/video/show.mkv',
|
||||||
|
getMpvExecutablePath: () => 'mpv',
|
||||||
|
generateWaveform: async () => [],
|
||||||
|
createPreviewSession: () => ({
|
||||||
|
start: async () => undefined,
|
||||||
|
play: async () => undefined,
|
||||||
|
stop: async () => undefined,
|
||||||
|
dispose: () => undefined,
|
||||||
|
}),
|
||||||
|
openModal: async () => true,
|
||||||
|
showStatus: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = runtime.requestReview({
|
||||||
|
kind: 'word',
|
||||||
|
text: '字幕',
|
||||||
|
startTime: 10,
|
||||||
|
endTime: 12,
|
||||||
|
audioPadding: 0,
|
||||||
|
maxMediaDuration: 30,
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
await runtime.dispose();
|
||||||
|
|
||||||
|
assert.deepEqual(await pending, { action: 'use-original' });
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['set_property', 'pause', 'no'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import type {
|
||||||
|
MediaTimingReviewActionResult,
|
||||||
|
MediaTimingReviewDecision,
|
||||||
|
MediaTimingReviewOpenPayload,
|
||||||
|
MediaTimingReviewPreviewRequest,
|
||||||
|
MediaTimingReviewRequest,
|
||||||
|
MediaTimingReviewResolveRequest,
|
||||||
|
MediaTimingReviewWaveformRequest,
|
||||||
|
MediaTimingReviewWaveformResult,
|
||||||
|
} from '../../types/anki';
|
||||||
|
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
|
||||||
|
|
||||||
|
const INITIAL_TIMELINE_MARGIN_SECONDS = 2;
|
||||||
|
const REVIEW_DECISION_TIMEOUT_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
interface ReviewMpvClient {
|
||||||
|
connected: boolean;
|
||||||
|
currentVideoPath: string;
|
||||||
|
currentAudioStreamIndex?: number | null;
|
||||||
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
|
send: (payload: { command: Array<string | number> }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewSession {
|
||||||
|
start(options: {
|
||||||
|
mediaPath: string;
|
||||||
|
executablePath?: string;
|
||||||
|
audioTrackId?: number;
|
||||||
|
volume?: number;
|
||||||
|
}): Promise<void>;
|
||||||
|
play(startTime: number, endTime: number): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveReview {
|
||||||
|
payload: MediaTimingReviewOpenPayload;
|
||||||
|
mediaPath: string;
|
||||||
|
audioStreamIndex?: number;
|
||||||
|
mpvClient: ReviewMpvClient;
|
||||||
|
restorePlayback: boolean;
|
||||||
|
preview: Promise<PreviewSession>;
|
||||||
|
resolve: (decision: MediaTimingReviewDecision) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaTimingReviewRuntimeDeps {
|
||||||
|
getMpvClient: () => ReviewMpvClient | null;
|
||||||
|
getCurrentMediaPath: () => string | null;
|
||||||
|
getMpvExecutablePath: () => string;
|
||||||
|
createPreviewSession: () => PreviewSession;
|
||||||
|
generateWaveform: (options: SpeechWaveformOptions) => Promise<number[]>;
|
||||||
|
decisionTimeoutMs?: number;
|
||||||
|
openModal: (payload: MediaTimingReviewOpenPayload) => Promise<boolean>;
|
||||||
|
showStatus: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value: unknown): number | null {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanProperty(value: unknown): boolean | null {
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (value === 'yes' || value === 1) return true;
|
||||||
|
if (value === 'no' || value === 0) return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidMediaTimingRange(
|
||||||
|
payload: MediaTimingReviewOpenPayload,
|
||||||
|
startTime: number,
|
||||||
|
endTime: number,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
Number.isFinite(startTime) &&
|
||||||
|
Number.isFinite(endTime) &&
|
||||||
|
startTime >= 0 &&
|
||||||
|
endTime > startTime &&
|
||||||
|
(payload.maxMediaDuration <= 0 || endTime - startTime <= payload.maxMediaDuration + 0.001) &&
|
||||||
|
(payload.mediaDuration === undefined || endTime <= payload.mediaDuration + 0.001)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMediaTimingReviewPayload(
|
||||||
|
request: MediaTimingReviewRequest,
|
||||||
|
options: { reviewId: string; mediaDuration?: number },
|
||||||
|
): MediaTimingReviewOpenPayload {
|
||||||
|
const duration = finiteNumber(options.mediaDuration);
|
||||||
|
const maxTime = duration !== null && duration > 0 ? duration : Number.POSITIVE_INFINITY;
|
||||||
|
const paddedStart = Math.max(0, request.startTime - request.audioPadding);
|
||||||
|
let paddedEnd = Math.min(maxTime, request.endTime + request.audioPadding);
|
||||||
|
const maxMediaDuration = Math.max(0, request.maxMediaDuration);
|
||||||
|
if (maxMediaDuration > 0 && paddedEnd - paddedStart > maxMediaDuration) {
|
||||||
|
paddedEnd = paddedStart + maxMediaDuration;
|
||||||
|
}
|
||||||
|
if (paddedEnd <= paddedStart) {
|
||||||
|
paddedEnd = Math.min(maxTime, paddedStart + 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timelineStartTime = Math.max(0, paddedStart - INITIAL_TIMELINE_MARGIN_SECONDS);
|
||||||
|
const timelineEndTime = Math.max(
|
||||||
|
paddedEnd,
|
||||||
|
Math.min(maxTime, paddedEnd + INITIAL_TIMELINE_MARGIN_SECONDS),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
reviewId: options.reviewId,
|
||||||
|
kind: request.kind,
|
||||||
|
text: request.text,
|
||||||
|
...(request.noteId !== undefined ? { noteId: request.noteId } : {}),
|
||||||
|
originalStartTime: request.startTime,
|
||||||
|
originalEndTime: request.endTime,
|
||||||
|
selectionStartTime: paddedStart,
|
||||||
|
selectionEndTime: paddedEnd,
|
||||||
|
timelineStartTime,
|
||||||
|
timelineEndTime,
|
||||||
|
...(duration !== null && duration > 0 ? { mediaDuration: duration } : {}),
|
||||||
|
maxMediaDuration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDeps) {
|
||||||
|
let active: ActiveReview | null = null;
|
||||||
|
let reviewInProgress = false;
|
||||||
|
let pendingPauseRestore: ReviewMpvClient | null = null;
|
||||||
|
|
||||||
|
function restorePendingPlayback(): void {
|
||||||
|
const mpvClient = pendingPauseRestore;
|
||||||
|
pendingPauseRestore = null;
|
||||||
|
if (mpvClient?.connected) {
|
||||||
|
mpvClient.send({ command: ['set_property', 'pause', 'no'] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runReview(request: MediaTimingReviewRequest): Promise<MediaTimingReviewDecision> {
|
||||||
|
const mpvClient = deps.getMpvClient();
|
||||||
|
const mediaPath =
|
||||||
|
deps.getCurrentMediaPath()?.trim() || mpvClient?.currentVideoPath?.trim() || '';
|
||||||
|
if (!mpvClient?.connected || !mediaPath) {
|
||||||
|
deps.showStatus('Timing review unavailable. Using the original subtitle timing.');
|
||||||
|
return { action: 'use-original' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw] = await Promise.all([
|
||||||
|
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
|
||||||
|
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
|
||||||
|
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
|
||||||
|
mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
|
||||||
|
]);
|
||||||
|
const pauseState = booleanProperty(pauseRaw);
|
||||||
|
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
|
||||||
|
pendingPauseRestore = pauseState === false ? mpvClient : null;
|
||||||
|
|
||||||
|
const payload = buildMediaTimingReviewPayload(request, {
|
||||||
|
reviewId: randomUUID(),
|
||||||
|
mediaDuration: finiteNumber(durationRaw) ?? undefined,
|
||||||
|
});
|
||||||
|
const previewSession = deps.createPreviewSession();
|
||||||
|
const preview = previewSession
|
||||||
|
.start({
|
||||||
|
mediaPath,
|
||||||
|
executablePath: deps.getMpvExecutablePath(),
|
||||||
|
audioTrackId: finiteNumber(audioTrackRaw) ?? undefined,
|
||||||
|
volume: finiteNumber(volumeRaw) ?? undefined,
|
||||||
|
})
|
||||||
|
.then(() => previewSession)
|
||||||
|
.catch((error) => {
|
||||||
|
previewSession.dispose();
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
void preview.catch(() => {});
|
||||||
|
|
||||||
|
let resolveDecision!: (decision: MediaTimingReviewDecision) => void;
|
||||||
|
const decisionPromise = new Promise<MediaTimingReviewDecision>((resolve) => {
|
||||||
|
resolveDecision = resolve;
|
||||||
|
});
|
||||||
|
active = {
|
||||||
|
payload,
|
||||||
|
mediaPath,
|
||||||
|
...(mpvClient.currentAudioStreamIndex !== null &&
|
||||||
|
mpvClient.currentAudioStreamIndex !== undefined
|
||||||
|
? { audioStreamIndex: mpvClient.currentAudioStreamIndex }
|
||||||
|
: {}),
|
||||||
|
mpvClient,
|
||||||
|
restorePlayback: pendingPauseRestore === mpvClient,
|
||||||
|
preview,
|
||||||
|
resolve: resolveDecision,
|
||||||
|
};
|
||||||
|
pendingPauseRestore = null;
|
||||||
|
|
||||||
|
const opened = await deps.openModal(payload).catch(() => false);
|
||||||
|
if (!opened) {
|
||||||
|
await cleanupActiveReview();
|
||||||
|
deps.showStatus('Timing review could not open. Using the original subtitle timing.');
|
||||||
|
return { action: 'use-original' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const decisionWatchdog = setTimeout(
|
||||||
|
() => resolveDecision({ action: 'use-original' }),
|
||||||
|
Math.max(0, deps.decisionTimeoutMs ?? REVIEW_DECISION_TIMEOUT_MS),
|
||||||
|
);
|
||||||
|
let decision: MediaTimingReviewDecision;
|
||||||
|
try {
|
||||||
|
decision = await decisionPromise;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(decisionWatchdog);
|
||||||
|
}
|
||||||
|
await cleanupActiveReview();
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestReview(
|
||||||
|
request: MediaTimingReviewRequest,
|
||||||
|
): Promise<MediaTimingReviewDecision> {
|
||||||
|
if (active || reviewInProgress) {
|
||||||
|
deps.showStatus('Finish the current timing review before mining another card.');
|
||||||
|
return { action: 'use-original' };
|
||||||
|
}
|
||||||
|
reviewInProgress = true;
|
||||||
|
try {
|
||||||
|
return await runReview(request);
|
||||||
|
} catch {
|
||||||
|
await cleanupActiveReview();
|
||||||
|
restorePendingPlayback();
|
||||||
|
deps.showStatus('Timing review failed. Using the original subtitle timing.');
|
||||||
|
return { action: 'use-original' };
|
||||||
|
} finally {
|
||||||
|
reviewInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewRange(
|
||||||
|
request: MediaTimingReviewPreviewRequest,
|
||||||
|
): Promise<MediaTimingReviewActionResult> {
|
||||||
|
const current = active;
|
||||||
|
if (!current || request.reviewId !== current.payload.reviewId) {
|
||||||
|
return { ok: false, message: 'This timing review is no longer active.' };
|
||||||
|
}
|
||||||
|
if (!isValidMediaTimingRange(current.payload, request.startTime, request.endTime)) {
|
||||||
|
return { ok: false, message: 'The selected preview range is invalid.' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const previewSession = await current.preview;
|
||||||
|
await previewSession.play(request.startTime, request.endTime);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `Audio preview unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWaveform(
|
||||||
|
request: MediaTimingReviewWaveformRequest,
|
||||||
|
): Promise<MediaTimingReviewWaveformResult> {
|
||||||
|
const current = active;
|
||||||
|
if (!current || request.reviewId !== current.payload.reviewId) {
|
||||||
|
return { ok: false, message: 'This timing review is no longer active.' };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isFinite(request.startTime) ||
|
||||||
|
!Number.isFinite(request.endTime) ||
|
||||||
|
request.startTime < 0 ||
|
||||||
|
request.endTime <= request.startTime ||
|
||||||
|
(current.payload.mediaDuration !== undefined &&
|
||||||
|
request.endTime > current.payload.mediaDuration + 0.001)
|
||||||
|
) {
|
||||||
|
return { ok: false, message: 'The waveform range is invalid.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const peaks = await deps.generateWaveform({
|
||||||
|
mediaPath: current.mediaPath,
|
||||||
|
startTime: request.startTime,
|
||||||
|
endTime: request.endTime,
|
||||||
|
...(current.audioStreamIndex !== undefined
|
||||||
|
? { audioStreamIndex: current.audioStreamIndex }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
if (active !== current || peaks.length < 2 || peaks.some((peak) => !Number.isFinite(peak))) {
|
||||||
|
return { ok: false, message: 'Timing waveform is unavailable.' };
|
||||||
|
}
|
||||||
|
return { ok: true, peaks };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, message: 'Timing waveform is unavailable.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopPreview(reviewId: string): Promise<MediaTimingReviewActionResult> {
|
||||||
|
const current = active;
|
||||||
|
if (!current || reviewId !== current.payload.reviewId) {
|
||||||
|
return { ok: false, message: 'This timing review is no longer active.' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const previewSession = await current.preview;
|
||||||
|
await previewSession.stop();
|
||||||
|
return { ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `Could not stop preview: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReview(request: MediaTimingReviewResolveRequest): MediaTimingReviewActionResult {
|
||||||
|
const current = active;
|
||||||
|
if (!current || request.reviewId !== current.payload.reviewId) {
|
||||||
|
return { ok: false, message: 'This timing review is no longer active.' };
|
||||||
|
}
|
||||||
|
if (request.decision.action === 'confirm') {
|
||||||
|
const { startTime, endTime } = request.decision;
|
||||||
|
if (!isValidMediaTimingRange(current.payload, startTime, endTime)) {
|
||||||
|
return { ok: false, message: 'The selected timing range is invalid.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.resolve(request.decision);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupActiveReview(): Promise<void> {
|
||||||
|
const current = active;
|
||||||
|
active = null;
|
||||||
|
if (!current) return;
|
||||||
|
void current.preview.then((session) => session.dispose()).catch(() => {});
|
||||||
|
if (current.restorePlayback && current.mpvClient.connected) {
|
||||||
|
current.mpvClient.send({ command: ['set_property', 'pause', 'no'] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispose(): Promise<void> {
|
||||||
|
active?.resolve({ action: 'use-original' });
|
||||||
|
await cleanupActiveReview();
|
||||||
|
restorePendingPlayback();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestReview,
|
||||||
|
previewRange,
|
||||||
|
getWaveform,
|
||||||
|
stopPreview,
|
||||||
|
resolveReview,
|
||||||
|
dispose,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -69,6 +69,11 @@ import type {
|
|||||||
OverlayNotificationEventPayload,
|
OverlayNotificationEventPayload,
|
||||||
OverlayNotificationPosition,
|
OverlayNotificationPosition,
|
||||||
ChangelogSnapshot,
|
ChangelogSnapshot,
|
||||||
|
MediaTimingReviewActionResult,
|
||||||
|
MediaTimingReviewOpenPayload,
|
||||||
|
MediaTimingReviewPreviewRequest,
|
||||||
|
MediaTimingReviewResolveRequest,
|
||||||
|
MediaTimingReviewWaveformRequest,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||||
|
|
||||||
@@ -181,6 +186,11 @@ const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<Youtube
|
|||||||
IPC_CHANNELS.event.youtubePickerOpen,
|
IPC_CHANNELS.event.youtubePickerOpen,
|
||||||
(payload) => payload as YoutubePickerOpenPayload,
|
(payload) => payload as YoutubePickerOpenPayload,
|
||||||
);
|
);
|
||||||
|
const onOpenMediaTimingReviewEvent =
|
||||||
|
createQueuedIpcListenerWithPayload<MediaTimingReviewOpenPayload>(
|
||||||
|
IPC_CHANNELS.event.mediaTimingReviewOpen,
|
||||||
|
(payload) => payload as MediaTimingReviewOpenPayload,
|
||||||
|
);
|
||||||
const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.playlistBrowserOpen);
|
const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.playlistBrowserOpen);
|
||||||
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
|
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
|
||||||
IPC_CHANNELS.event.youtubePickerCancel,
|
IPC_CHANNELS.event.youtubePickerCancel,
|
||||||
@@ -458,6 +468,19 @@ const electronAPI: ElectronAPI = {
|
|||||||
onOpenJimaku: onOpenJimakuEvent,
|
onOpenJimaku: onOpenJimakuEvent,
|
||||||
onOpenTsukihime: onOpenTsukihimeEvent,
|
onOpenTsukihime: onOpenTsukihimeEvent,
|
||||||
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
|
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
|
||||||
|
onOpenMediaTimingReview: onOpenMediaTimingReviewEvent,
|
||||||
|
previewMediaTimingReview: (
|
||||||
|
request: MediaTimingReviewPreviewRequest,
|
||||||
|
): Promise<MediaTimingReviewActionResult> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewPreview, request),
|
||||||
|
getMediaTimingReviewWaveform: (request: MediaTimingReviewWaveformRequest) =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewWaveform, request),
|
||||||
|
stopMediaTimingReviewPreview: (reviewId: string): Promise<MediaTimingReviewActionResult> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewStopPreview, reviewId),
|
||||||
|
resolveMediaTimingReview: (
|
||||||
|
request: MediaTimingReviewResolveRequest,
|
||||||
|
): Promise<MediaTimingReviewActionResult> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewResolve, request),
|
||||||
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
|
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
|
||||||
onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent,
|
onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent,
|
||||||
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
|
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
|
||||||
|
|||||||
@@ -452,6 +452,7 @@ function createKeyboardHandlerHarness() {
|
|||||||
const testGlobals = installKeyboardTestGlobals();
|
const testGlobals = installKeyboardTestGlobals();
|
||||||
const subtitleRootClassList = createClassList();
|
const subtitleRootClassList = createClassList();
|
||||||
const subtitleContainerClassList = createClassList();
|
const subtitleContainerClassList = createClassList();
|
||||||
|
let mediaTimingReviewKeydownCount = 0;
|
||||||
let controllerSelectKeydownCount = 0;
|
let controllerSelectKeydownCount = 0;
|
||||||
let openControllerSelectCount = 0;
|
let openControllerSelectCount = 0;
|
||||||
let openControllerDebugCount = 0;
|
let openControllerDebugCount = 0;
|
||||||
@@ -494,6 +495,10 @@ function createKeyboardHandlerHarness() {
|
|||||||
handleKikuKeydown: () => false,
|
handleKikuKeydown: () => false,
|
||||||
handleJimakuKeydown: () => false,
|
handleJimakuKeydown: () => false,
|
||||||
handleTsukihimeKeydown: () => false,
|
handleTsukihimeKeydown: () => false,
|
||||||
|
handleMediaTimingReviewKeydown: () => {
|
||||||
|
mediaTimingReviewKeydownCount += 1;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
handleControllerSelectKeydown: () => {
|
handleControllerSelectKeydown: () => {
|
||||||
controllerSelectKeydownCount += 1;
|
controllerSelectKeydownCount += 1;
|
||||||
return true;
|
return true;
|
||||||
@@ -523,6 +528,7 @@ function createKeyboardHandlerHarness() {
|
|||||||
ctx,
|
ctx,
|
||||||
handlers,
|
handlers,
|
||||||
testGlobals,
|
testGlobals,
|
||||||
|
mediaTimingReviewKeydownCount: () => mediaTimingReviewKeydownCount,
|
||||||
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
||||||
openControllerSelectCount: () => openControllerSelectCount,
|
openControllerSelectCount: () => openControllerSelectCount,
|
||||||
openControllerDebugCount: () => openControllerDebugCount,
|
openControllerDebugCount: () => openControllerDebugCount,
|
||||||
@@ -1367,6 +1373,29 @@ test('keyboard mode: controller select modal handles arrow keys before yomitan p
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('media timing review modal handles keys before later modal handlers', async () => {
|
||||||
|
const {
|
||||||
|
ctx,
|
||||||
|
testGlobals,
|
||||||
|
handlers,
|
||||||
|
mediaTimingReviewKeydownCount,
|
||||||
|
controllerSelectKeydownCount,
|
||||||
|
} = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
ctx.state.mediaTimingReviewModalOpen = true;
|
||||||
|
ctx.state.controllerSelectModalOpen = true;
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
|
||||||
|
|
||||||
|
assert.equal(mediaTimingReviewKeydownCount(), 1);
|
||||||
|
assert.equal(controllerSelectKeydownCount(), 0);
|
||||||
|
} finally {
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('keyboard mode: playlist browser modal handles arrow keys before yomitan popup', async () => {
|
test('keyboard mode: playlist browser modal handles arrow keys before yomitan popup', async () => {
|
||||||
const { ctx, testGlobals, handlers, playlistBrowserKeydownCount } =
|
const { ctx, testGlobals, handlers, playlistBrowserKeydownCount } =
|
||||||
createKeyboardHandlerHarness();
|
createKeyboardHandlerHarness();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function createKeyboardHandlers(
|
|||||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleTsukihimeKeydown: (e: KeyboardEvent) => boolean;
|
handleTsukihimeKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
|
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
|
||||||
|
handleMediaTimingReviewKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
|
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||||
@@ -1078,6 +1079,11 @@ export function createKeyboardHandlers(
|
|||||||
);
|
);
|
||||||
|
|
||||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if (ctx.state.mediaTimingReviewModalOpen) {
|
||||||
|
options.handleMediaTimingReviewKeydown(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isKeyboardDrivenModeToggle(e) && ctx.platform.isModalLayer) {
|
if (isKeyboardDrivenModeToggle(e) && ctx.platform.isModalLayer) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleKeyboardModeToggleRequested();
|
handleKeyboardModeToggleRequested();
|
||||||
|
|||||||
@@ -196,6 +196,232 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
id="mediaTimingReviewModal"
|
||||||
|
class="modal media-timing-review-modal hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="modal-content media-timing-review-content"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="mediaTimingReviewTitle"
|
||||||
|
>
|
||||||
|
<div class="media-timing-review-header">
|
||||||
|
<div class="media-timing-review-heading">
|
||||||
|
<div id="mediaTimingReviewTitle" class="media-timing-review-title">
|
||||||
|
Review media timing
|
||||||
|
</div>
|
||||||
|
<div id="mediaTimingReviewKind" class="media-timing-review-kind">Sentence card</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="mediaTimingReviewCancel"
|
||||||
|
class="media-timing-review-quiet-button"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mediaTimingReviewEditor" class="media-timing-review-editor">
|
||||||
|
<blockquote id="mediaTimingReviewText" class="media-timing-review-text"></blockquote>
|
||||||
|
|
||||||
|
<div class="media-timing-review-readout" aria-live="polite">
|
||||||
|
<div>
|
||||||
|
<span>Starts</span>
|
||||||
|
<strong id="mediaTimingReviewStartValue">00:00.000</strong>
|
||||||
|
</div>
|
||||||
|
<div class="media-timing-review-duration">
|
||||||
|
<span>Clip length</span>
|
||||||
|
<strong id="mediaTimingReviewDuration">0.00s</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Ends</span>
|
||||||
|
<strong id="mediaTimingReviewEndValue">00:00.000</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="media-timing-review-timeline-shell">
|
||||||
|
<div class="media-timing-review-timeline-labels">
|
||||||
|
<span id="mediaTimingReviewTimelineStart">00:00</span>
|
||||||
|
<span id="mediaTimingReviewWaveformLabel">speech-weighted waveform</span>
|
||||||
|
<span id="mediaTimingReviewTimelineEnd">00:00</span>
|
||||||
|
</div>
|
||||||
|
<div id="mediaTimingReviewSelectionTrack" class="media-timing-review-track">
|
||||||
|
<svg
|
||||||
|
class="media-timing-review-waveform"
|
||||||
|
viewBox="0 0 1000 100"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="mediaTimingReviewWaveformFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop class="media-timing-review-waveform-edge-stop" offset="0%" />
|
||||||
|
<stop class="media-timing-review-waveform-core-stop" offset="50%" />
|
||||||
|
<stop class="media-timing-review-waveform-edge-stop" offset="100%" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path id="mediaTimingReviewWaveformPath"></path>
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
class="media-timing-review-original-range"
|
||||||
|
title="Mined subtitle timing"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span class="media-timing-review-original-boundary is-start"></span>
|
||||||
|
<span class="media-timing-review-original-boundary is-end"></span>
|
||||||
|
</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-orientation="horizontal"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
id="mediaTimingReviewEndHandle"
|
||||||
|
class="media-timing-review-handle media-timing-review-handle-end"
|
||||||
|
role="slider"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Clip end"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="media-timing-review-expand-row">
|
||||||
|
<button
|
||||||
|
id="mediaTimingReviewShowEarlier"
|
||||||
|
class="media-timing-review-expand-button"
|
||||||
|
type="button"
|
||||||
|
aria-label="Reveal five more seconds before the timeline"
|
||||||
|
>
|
||||||
|
−5s
|
||||||
|
</button>
|
||||||
|
<span>Drag an edge to trim, or drag the middle to slide the clip.</span>
|
||||||
|
<button
|
||||||
|
id="mediaTimingReviewShowLater"
|
||||||
|
class="media-timing-review-expand-button"
|
||||||
|
type="button"
|
||||||
|
aria-label="Reveal five more seconds after the timeline"
|
||||||
|
>
|
||||||
|
+5s
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="media-timing-review-fine-grid">
|
||||||
|
<div class="media-timing-review-fine-control">
|
||||||
|
<span>Start</span>
|
||||||
|
<button
|
||||||
|
id="mediaTimingReviewStartBack"
|
||||||
|
type="button"
|
||||||
|
aria-label="Move start backward 100 milliseconds"
|
||||||
|
>
|
||||||
|
−0.1s
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="mediaTimingReviewStartForward"
|
||||||
|
type="button"
|
||||||
|
aria-label="Move start forward 100 milliseconds"
|
||||||
|
>
|
||||||
|
+0.1s
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="media-timing-review-fine-control">
|
||||||
|
<span>End</span>
|
||||||
|
<button
|
||||||
|
id="mediaTimingReviewEndBack"
|
||||||
|
type="button"
|
||||||
|
aria-label="Move end backward 100 milliseconds"
|
||||||
|
>
|
||||||
|
−0.1s
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="mediaTimingReviewEndForward"
|
||||||
|
type="button"
|
||||||
|
aria-label="Move end forward 100 milliseconds"
|
||||||
|
>
|
||||||
|
+0.1s
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="mediaTimingReviewStatus"
|
||||||
|
class="media-timing-review-status"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
></div>
|
||||||
|
<div class="media-timing-review-footer">
|
||||||
|
<div class="media-timing-review-preview-actions">
|
||||||
|
<button
|
||||||
|
id="mediaTimingReviewPlay"
|
||||||
|
class="media-timing-review-play-button"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="media-timing-review-play-glyph" aria-hidden="true"></span>
|
||||||
|
<span id="mediaTimingReviewPlayLabel">Play selection</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="mediaTimingReviewReset"
|
||||||
|
class="media-timing-review-quiet-button"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Reset timing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="mediaTimingReviewConfirm"
|
||||||
|
class="media-timing-review-confirm-button"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Use this timing
|
||||||
|
</button>
|
||||||
|
</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 id="mediaTimingReviewCancelStep" class="media-timing-review-cancel-step hidden">
|
||||||
|
<div class="media-timing-review-cancel-mark" aria-hidden="true">?</div>
|
||||||
|
<h2>Stop reviewing?</h2>
|
||||||
|
<p id="mediaTimingReviewCancelMessage">Choose what should happen to this card.</p>
|
||||||
|
<div class="media-timing-review-cancel-actions">
|
||||||
|
<button
|
||||||
|
id="mediaTimingReviewCancelBack"
|
||||||
|
class="media-timing-review-quiet-button"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Keep editing
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="mediaTimingReviewUseOriginal"
|
||||||
|
class="media-timing-review-original-button"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Use original timing
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="mediaTimingReviewDiscard"
|
||||||
|
class="media-timing-review-discard-button"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Delete card
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="kikuFieldGroupingModal" class="modal hidden" aria-hidden="true">
|
<div id="kikuFieldGroupingModal" class="modal hidden" aria-hidden="true">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
buildMediaTimingWaveformPath,
|
||||||
|
constrainMediaTimingSelection,
|
||||||
|
createMediaTimingPreviewRequestGuard,
|
||||||
|
formatMediaTimingTimestamp,
|
||||||
|
mediaTimingTimeFromPointer,
|
||||||
|
slideMediaTimingSelection,
|
||||||
|
} from './media-timing-review';
|
||||||
|
|
||||||
|
test('waveform path mirrors normalized peaks around its center line', () => {
|
||||||
|
const path = buildMediaTimingWaveformPath([0, 0.5, 1]);
|
||||||
|
|
||||||
|
assert.match(path, /^M 0\.00 50\.00 L 500\.00 28\.00 L 1000\.00 6\.00/);
|
||||||
|
assert.match(path, /1000\.00 94\.00 L 500\.00 72\.00 L 0\.00 50\.00 Z$/);
|
||||||
|
assert.equal(buildMediaTimingWaveformPath([1]), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatMediaTimingTimestamp renders stable review readouts', () => {
|
||||||
|
assert.equal(formatMediaTimingTimestamp(65.4321), '01:05.432');
|
||||||
|
assert.equal(formatMediaTimingTimestamp(65.4321, false), '01:05');
|
||||||
|
assert.equal(formatMediaTimingTimestamp(-1), '00:00.000');
|
||||||
|
assert.equal(formatMediaTimingTimestamp(119.9999), '02:00.000');
|
||||||
|
assert.equal(formatMediaTimingTimestamp(119.6, false), '02:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selection constraints preserve the handle that did not move', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
constrainMediaTimingSelection({
|
||||||
|
nextStart: 3,
|
||||||
|
nextEnd: 2,
|
||||||
|
currentStart: 1,
|
||||||
|
timelineStart: 0,
|
||||||
|
timelineEnd: 10,
|
||||||
|
mediaEnd: 10,
|
||||||
|
maxMediaDuration: 30,
|
||||||
|
}),
|
||||||
|
{ start: 1.9, end: 2 },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
constrainMediaTimingSelection({
|
||||||
|
nextStart: 1,
|
||||||
|
nextEnd: 0.5,
|
||||||
|
currentStart: 1,
|
||||||
|
timelineStart: 0,
|
||||||
|
timelineEnd: 10,
|
||||||
|
mediaEnd: 10,
|
||||||
|
maxMediaDuration: 30,
|
||||||
|
}),
|
||||||
|
{ start: 1, end: 1.1 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pointer positions map onto the visible timeline and clamp past its edges', () => {
|
||||||
|
const track = { trackLeft: 100, trackWidth: 400, timelineStart: 10, timelineEnd: 20 };
|
||||||
|
|
||||||
|
assert.equal(mediaTimingTimeFromPointer({ clientX: 100, ...track }), 10);
|
||||||
|
assert.equal(mediaTimingTimeFromPointer({ clientX: 300, ...track }), 15);
|
||||||
|
assert.equal(mediaTimingTimeFromPointer({ clientX: -40, ...track }), 10);
|
||||||
|
assert.equal(mediaTimingTimeFromPointer({ clientX: 900, ...track }), 20);
|
||||||
|
assert.equal(mediaTimingTimeFromPointer({ ...track, clientX: 300, trackWidth: 0 }), 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sliding keeps the clip length and stops at the timeline and media bounds', () => {
|
||||||
|
const timeline = { span: 2, timelineStart: 4, timelineEnd: 12, mediaEnd: 10 };
|
||||||
|
|
||||||
|
assert.deepEqual(slideMediaTimingSelection({ nextStart: 6, ...timeline }), { start: 6, end: 8 });
|
||||||
|
assert.deepEqual(slideMediaTimingSelection({ nextStart: 1, ...timeline }), { start: 4, end: 6 });
|
||||||
|
assert.deepEqual(slideMediaTimingSelection({ nextStart: 99, ...timeline }), {
|
||||||
|
start: 8,
|
||||||
|
end: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preview request guard blocks overlap and invalidates stale responses', () => {
|
||||||
|
const guard = createMediaTimingPreviewRequestGuard();
|
||||||
|
const first = guard.begin();
|
||||||
|
|
||||||
|
assert.equal(typeof first, 'number');
|
||||||
|
assert.equal(guard.begin(), null);
|
||||||
|
assert.equal(guard.isCurrent(first!), true);
|
||||||
|
|
||||||
|
guard.invalidate();
|
||||||
|
assert.equal(guard.isCurrent(first!), false);
|
||||||
|
assert.equal(guard.isInFlight(), false);
|
||||||
|
|
||||||
|
const second = guard.begin();
|
||||||
|
assert.equal(typeof second, 'number');
|
||||||
|
guard.finish(first!);
|
||||||
|
assert.equal(guard.isCurrent(second!), true);
|
||||||
|
guard.finish(second!);
|
||||||
|
assert.equal(guard.isInFlight(), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
import type { MediaTimingReviewDecision, MediaTimingReviewOpenPayload } from '../../types/anki';
|
||||||
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
|
import { createModalFocusGuard } from './modal-focus-guard';
|
||||||
|
|
||||||
|
const MINIMUM_CLIP_SECONDS = 0.1;
|
||||||
|
const FINE_ADJUST_SECONDS = 0.1;
|
||||||
|
const COARSE_ADJUST_SECONDS = 0.5;
|
||||||
|
const TIMELINE_EXPANSION_SECONDS = 5;
|
||||||
|
|
||||||
|
function clamp(value: number, minimum: number, maximum: number): number {
|
||||||
|
return Math.min(maximum, Math.max(minimum, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMediaTimingTimestamp(seconds: number, includeMilliseconds = true): string {
|
||||||
|
const unitsPerSecond = includeMilliseconds ? 1_000 : 1;
|
||||||
|
const totalUnits = Math.max(0, Math.round(seconds * unitsPerSecond));
|
||||||
|
const unitsPerMinute = 60 * unitsPerSecond;
|
||||||
|
const minutes = Math.floor(totalUnits / unitsPerMinute);
|
||||||
|
const remainingUnits = totalUnits % unitsPerMinute;
|
||||||
|
const remaining = includeMilliseconds
|
||||||
|
? (remainingUnits / unitsPerSecond).toFixed(3)
|
||||||
|
: String(remainingUnits);
|
||||||
|
return `${String(minutes).padStart(2, '0')}:${remaining.padStart(includeMilliseconds ? 6 : 2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMediaTimingPreviewRequestGuard() {
|
||||||
|
let sequence = 0;
|
||||||
|
let activeRequestId: number | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
begin(): number | null {
|
||||||
|
if (activeRequestId !== null) return null;
|
||||||
|
sequence += 1;
|
||||||
|
activeRequestId = sequence;
|
||||||
|
return activeRequestId;
|
||||||
|
},
|
||||||
|
invalidate(): void {
|
||||||
|
sequence += 1;
|
||||||
|
activeRequestId = null;
|
||||||
|
},
|
||||||
|
isCurrent(requestId: number): boolean {
|
||||||
|
return requestId === activeRequestId;
|
||||||
|
},
|
||||||
|
finish(requestId: number): void {
|
||||||
|
if (activeRequestId === requestId) activeRequestId = null;
|
||||||
|
},
|
||||||
|
isInFlight(): boolean {
|
||||||
|
return activeRequestId !== null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMediaTimingWaveformPath(peaks: number[]): string {
|
||||||
|
if (peaks.length < 2) return '';
|
||||||
|
const points = peaks.map((peak, index) => ({
|
||||||
|
x: (index / (peaks.length - 1)) * 1_000,
|
||||||
|
amplitude: clamp(Number.isFinite(peak) ? peak : 0, 0, 1) * 44,
|
||||||
|
}));
|
||||||
|
const upper = points.map(({ x, amplitude }) => `${x.toFixed(2)} ${(50 - amplitude).toFixed(2)}`);
|
||||||
|
const lower = [...points]
|
||||||
|
.reverse()
|
||||||
|
.map(({ x, amplitude }) => `${x.toFixed(2)} ${(50 + amplitude).toFixed(2)}`);
|
||||||
|
return `M ${upper.join(' L ')} L ${lower.join(' L ')} Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function constrainMediaTimingSelection(options: {
|
||||||
|
nextStart: number;
|
||||||
|
nextEnd: number;
|
||||||
|
currentStart: number;
|
||||||
|
timelineStart: number;
|
||||||
|
timelineEnd: number;
|
||||||
|
mediaEnd: number;
|
||||||
|
maxMediaDuration: number;
|
||||||
|
}): { start: number; end: number } {
|
||||||
|
let start = clamp(options.nextStart, options.timelineStart, options.timelineEnd);
|
||||||
|
let end = clamp(options.nextEnd, options.timelineStart, options.timelineEnd);
|
||||||
|
const startMoved = options.nextStart !== options.currentStart;
|
||||||
|
if (end - start < MINIMUM_CLIP_SECONDS) {
|
||||||
|
if (startMoved) start = end - MINIMUM_CLIP_SECONDS;
|
||||||
|
else end = start + MINIMUM_CLIP_SECONDS;
|
||||||
|
}
|
||||||
|
if (options.maxMediaDuration > 0 && end - start > options.maxMediaDuration) {
|
||||||
|
if (startMoved) start = end - options.maxMediaDuration;
|
||||||
|
else end = start + options.maxMediaDuration;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
start: Math.max(0, start),
|
||||||
|
end: Math.min(options.mediaEnd, end),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a pointer position over the timeline track back onto a media timestamp. */
|
||||||
|
export function mediaTimingTimeFromPointer(options: {
|
||||||
|
clientX: number;
|
||||||
|
trackLeft: number;
|
||||||
|
trackWidth: number;
|
||||||
|
timelineStart: number;
|
||||||
|
timelineEnd: number;
|
||||||
|
}): number {
|
||||||
|
if (options.trackWidth <= 0) return options.timelineStart;
|
||||||
|
const ratio = clamp((options.clientX - options.trackLeft) / options.trackWidth, 0, 1);
|
||||||
|
return options.timelineStart + ratio * (options.timelineEnd - options.timelineStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slides the selection without resizing it, keeping the whole clip inside the visible timeline. */
|
||||||
|
export function slideMediaTimingSelection(options: {
|
||||||
|
nextStart: number;
|
||||||
|
span: number;
|
||||||
|
timelineStart: number;
|
||||||
|
timelineEnd: number;
|
||||||
|
mediaEnd: number;
|
||||||
|
}): { start: number; end: number } {
|
||||||
|
const latestEnd = Math.min(options.timelineEnd, options.mediaEnd);
|
||||||
|
const maxStart = Math.max(options.timelineStart, latestEnd - options.span);
|
||||||
|
const start = clamp(options.nextStart, options.timelineStart, maxStart);
|
||||||
|
return { start, end: start + options.span };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMediaTimingReviewModal(
|
||||||
|
ctx: RendererContext,
|
||||||
|
options: {
|
||||||
|
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||||
|
syncSettingsModalSubtitleSuppression: () => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
let payload: MediaTimingReviewOpenPayload | null = null;
|
||||||
|
let selectionStart = 0;
|
||||||
|
let selectionEnd = 0;
|
||||||
|
let timelineStart = 0;
|
||||||
|
let timelineEnd = 0;
|
||||||
|
let resolveInFlight = false;
|
||||||
|
let previewPlaying = false;
|
||||||
|
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let waveformTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let waveformSequence = 0;
|
||||||
|
let drag: {
|
||||||
|
edge: 'start' | 'end' | 'both';
|
||||||
|
pointerId: number;
|
||||||
|
trackLeft: number;
|
||||||
|
trackWidth: number;
|
||||||
|
grabOffset: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
const focus = createModalFocusGuard({
|
||||||
|
isOpen: () => ctx.state.mediaTimingReviewModalOpen,
|
||||||
|
getModalRoot: () => ctx.dom.mediaTimingReviewModal,
|
||||||
|
getPreferredFocusTargets: () => [
|
||||||
|
ctx.dom.mediaTimingReviewStartHandle,
|
||||||
|
ctx.dom.mediaTimingReviewCancelBack,
|
||||||
|
],
|
||||||
|
getFallbackFocusTarget: () => ctx.dom.mediaTimingReviewCancel,
|
||||||
|
isModalLayer: ctx.platform.isModalLayer,
|
||||||
|
});
|
||||||
|
const previewRequest = createMediaTimingPreviewRequestGuard();
|
||||||
|
|
||||||
|
function setStatus(message: string, isError = false): void {
|
||||||
|
ctx.dom.mediaTimingReviewStatus.textContent = message;
|
||||||
|
ctx.dom.mediaTimingReviewStatus.classList.toggle('is-error', isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPreviewTimer(): void {
|
||||||
|
if (previewTimer !== null) clearTimeout(previewTimer);
|
||||||
|
previewTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drives the play button label plus the playhead sweep that mirrors the hidden audio player. */
|
||||||
|
function setPreviewPlaying(playing: boolean): void {
|
||||||
|
previewPlaying = playing;
|
||||||
|
ctx.dom.mediaTimingReviewPlayLabel.textContent = playing ? 'Stop preview' : 'Play selection';
|
||||||
|
ctx.dom.mediaTimingReviewPlay.classList.toggle('is-playing', playing);
|
||||||
|
clearPreviewTimer();
|
||||||
|
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
||||||
|
track.classList.remove('is-previewing');
|
||||||
|
if (!playing) return;
|
||||||
|
const clipSeconds = Math.max(MINIMUM_CLIP_SECONDS, selectionEnd - selectionStart);
|
||||||
|
track.style.setProperty('--playhead-duration', `${clipSeconds}s`);
|
||||||
|
void track.offsetWidth;
|
||||||
|
track.classList.add('is-previewing');
|
||||||
|
previewTimer = setTimeout(() => stopPreview(), clipSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Callers that need to report a failure set their own status after stopping the preview. */
|
||||||
|
function stopPreview(): void {
|
||||||
|
previewRequest.invalidate();
|
||||||
|
setPreviewPlaying(false);
|
||||||
|
setStatus('');
|
||||||
|
if (payload) {
|
||||||
|
void window.electronAPI.stopMediaTimingReviewPreview(payload.reviewId).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHandle(handle: HTMLDivElement, value: number): void {
|
||||||
|
handle.setAttribute('aria-valuemin', timelineStart.toFixed(3));
|
||||||
|
handle.setAttribute('aria-valuemax', timelineEnd.toFixed(3));
|
||||||
|
handle.setAttribute('aria-valuenow', value.toFixed(3));
|
||||||
|
handle.setAttribute('aria-valuetext', formatMediaTimingTimestamp(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelection(): void {
|
||||||
|
const span = Math.max(MINIMUM_CLIP_SECONDS, timelineEnd - timelineStart);
|
||||||
|
const startPercent = ((selectionStart - timelineStart) / span) * 100;
|
||||||
|
const endPercent = ((selectionEnd - timelineStart) / span) * 100;
|
||||||
|
const originalStartPercent = payload
|
||||||
|
? ((payload.originalStartTime - timelineStart) / span) * 100
|
||||||
|
: 0;
|
||||||
|
const originalEndPercent = payload
|
||||||
|
? ((payload.originalEndTime - timelineStart) / span) * 100
|
||||||
|
: 0;
|
||||||
|
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
||||||
|
'--selection-start',
|
||||||
|
`${clamp(startPercent, 0, 100)}%`,
|
||||||
|
);
|
||||||
|
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
||||||
|
'--selection-end',
|
||||||
|
`${clamp(endPercent, 0, 100)}%`,
|
||||||
|
);
|
||||||
|
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
||||||
|
'--original-start',
|
||||||
|
`${clamp(originalStartPercent, 0, 100)}%`,
|
||||||
|
);
|
||||||
|
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
||||||
|
'--original-end',
|
||||||
|
`${clamp(originalEndPercent, 0, 100)}%`,
|
||||||
|
);
|
||||||
|
renderHandle(ctx.dom.mediaTimingReviewStartHandle, selectionStart);
|
||||||
|
renderHandle(ctx.dom.mediaTimingReviewEndHandle, selectionEnd);
|
||||||
|
ctx.dom.mediaTimingReviewStartValue.textContent = formatMediaTimingTimestamp(selectionStart);
|
||||||
|
ctx.dom.mediaTimingReviewEndValue.textContent = formatMediaTimingTimestamp(selectionEnd);
|
||||||
|
ctx.dom.mediaTimingReviewDuration.textContent = `${(selectionEnd - selectionStart).toFixed(2)}s`;
|
||||||
|
ctx.dom.mediaTimingReviewTimelineStart.textContent = formatMediaTimingTimestamp(
|
||||||
|
timelineStart,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
ctx.dom.mediaTimingReviewTimelineEnd.textContent = formatMediaTimingTimestamp(
|
||||||
|
timelineEnd,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
ctx.dom.mediaTimingReviewShowEarlier.disabled = timelineStart <= 0;
|
||||||
|
ctx.dom.mediaTimingReviewShowLater.disabled =
|
||||||
|
payload?.mediaDuration !== undefined && timelineEnd >= payload.mediaDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWaveformState(state: 'loading' | 'ready' | 'unavailable'): void {
|
||||||
|
ctx.dom.mediaTimingReviewSelectionTrack.classList.toggle('is-loading', state === 'loading');
|
||||||
|
ctx.dom.mediaTimingReviewSelectionTrack.classList.toggle(
|
||||||
|
'is-waveform-unavailable',
|
||||||
|
state === 'unavailable',
|
||||||
|
);
|
||||||
|
ctx.dom.mediaTimingReviewWaveformLabel.textContent =
|
||||||
|
state === 'loading'
|
||||||
|
? 'isolating dialogue...'
|
||||||
|
: state === 'ready'
|
||||||
|
? 'speech-weighted waveform'
|
||||||
|
: 'waveform unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWaveform(): Promise<void> {
|
||||||
|
if (!payload || !ctx.state.mediaTimingReviewModalOpen) return;
|
||||||
|
waveformSequence += 1;
|
||||||
|
const sequence = waveformSequence;
|
||||||
|
const reviewId = payload.reviewId;
|
||||||
|
const startTime = timelineStart;
|
||||||
|
const endTime = timelineEnd;
|
||||||
|
setWaveformState('loading');
|
||||||
|
ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', '');
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.getMediaTimingReviewWaveform({
|
||||||
|
reviewId,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
sequence !== waveformSequence ||
|
||||||
|
payload?.reviewId !== reviewId ||
|
||||||
|
timelineStart !== startTime ||
|
||||||
|
timelineEnd !== endTime
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const path = result.ok ? buildMediaTimingWaveformPath(result.peaks ?? []) : '';
|
||||||
|
if (!path) {
|
||||||
|
setWaveformState('unavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', path);
|
||||||
|
setWaveformState('ready');
|
||||||
|
} catch {
|
||||||
|
if (sequence === waveformSequence && payload?.reviewId === reviewId) {
|
||||||
|
setWaveformState('unavailable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueWaveformLoad(delayMs = 0): void {
|
||||||
|
if (waveformTimer !== null) clearTimeout(waveformTimer);
|
||||||
|
waveformSequence += 1;
|
||||||
|
if (payload && ctx.state.mediaTimingReviewModalOpen) {
|
||||||
|
ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', '');
|
||||||
|
setWaveformState('loading');
|
||||||
|
}
|
||||||
|
waveformTimer = setTimeout(() => {
|
||||||
|
waveformTimer = null;
|
||||||
|
void loadWaveform();
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelection(nextStart: number, nextEnd: number): void {
|
||||||
|
if (!payload) return;
|
||||||
|
const mediaEnd = payload.mediaDuration ?? Number.POSITIVE_INFINITY;
|
||||||
|
const nextSelection = constrainMediaTimingSelection({
|
||||||
|
nextStart,
|
||||||
|
nextEnd,
|
||||||
|
currentStart: selectionStart,
|
||||||
|
timelineStart,
|
||||||
|
timelineEnd,
|
||||||
|
mediaEnd,
|
||||||
|
maxMediaDuration: payload.maxMediaDuration,
|
||||||
|
});
|
||||||
|
selectionStart = nextSelection.start;
|
||||||
|
selectionEnd = nextSelection.end;
|
||||||
|
if (previewPlaying || previewRequest.isInFlight()) stopPreview();
|
||||||
|
setStatus('');
|
||||||
|
renderSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shifts the whole clip without changing its length. */
|
||||||
|
function moveSelection(nextStart: number): void {
|
||||||
|
if (!payload) return;
|
||||||
|
const slid = slideMediaTimingSelection({
|
||||||
|
nextStart,
|
||||||
|
span: selectionEnd - selectionStart,
|
||||||
|
timelineStart,
|
||||||
|
timelineEnd,
|
||||||
|
mediaEnd: payload.mediaDuration ?? Number.POSITIVE_INFINITY,
|
||||||
|
});
|
||||||
|
updateSelection(slid.start, slid.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEdge(edge: 'start' | 'end', value: number): void {
|
||||||
|
if (edge === 'start') updateSelection(value, selectionEnd);
|
||||||
|
else updateSelection(selectionStart, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDrag(clientX: number): void {
|
||||||
|
if (!drag) return;
|
||||||
|
const pointerTime = mediaTimingTimeFromPointer({
|
||||||
|
clientX,
|
||||||
|
trackLeft: drag.trackLeft,
|
||||||
|
trackWidth: drag.trackWidth,
|
||||||
|
timelineStart,
|
||||||
|
timelineEnd,
|
||||||
|
});
|
||||||
|
const time = pointerTime - drag.grabOffset;
|
||||||
|
if (drag.edge === 'both') moveSelection(time);
|
||||||
|
else setEdge(drag.edge, time);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grabbing a handle drags that edge, grabbing the highlighted clip slides the whole selection,
|
||||||
|
* and pressing anywhere else snaps the nearest edge to that point and keeps dragging it.
|
||||||
|
*/
|
||||||
|
function beginDrag(event: PointerEvent): void {
|
||||||
|
if (!payload || resolveInFlight || drag !== null || event.button !== 0) return;
|
||||||
|
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
||||||
|
const rect = track.getBoundingClientRect();
|
||||||
|
const pointerTime = mediaTimingTimeFromPointer({
|
||||||
|
clientX: event.clientX,
|
||||||
|
trackLeft: rect.left,
|
||||||
|
trackWidth: rect.width,
|
||||||
|
timelineStart,
|
||||||
|
timelineEnd,
|
||||||
|
});
|
||||||
|
const target = event.target;
|
||||||
|
let edge: 'start' | 'end' | 'both';
|
||||||
|
let grabOffset: number;
|
||||||
|
if (target === ctx.dom.mediaTimingReviewStartHandle) {
|
||||||
|
edge = 'start';
|
||||||
|
grabOffset = pointerTime - selectionStart;
|
||||||
|
} else if (target === ctx.dom.mediaTimingReviewEndHandle) {
|
||||||
|
edge = 'end';
|
||||||
|
grabOffset = pointerTime - selectionEnd;
|
||||||
|
} else if (target === ctx.dom.mediaTimingReviewSelectedRange) {
|
||||||
|
edge = 'both';
|
||||||
|
grabOffset = pointerTime - selectionStart;
|
||||||
|
} else {
|
||||||
|
edge =
|
||||||
|
Math.abs(pointerTime - selectionStart) <= Math.abs(pointerTime - selectionEnd)
|
||||||
|
? 'start'
|
||||||
|
: 'end';
|
||||||
|
grabOffset = 0;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
drag = {
|
||||||
|
edge,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
trackLeft: rect.left,
|
||||||
|
trackWidth: rect.width,
|
||||||
|
grabOffset,
|
||||||
|
};
|
||||||
|
track.setPointerCapture(event.pointerId);
|
||||||
|
ctx.dom.mediaTimingReviewModal.classList.add(edge === 'both' ? 'is-sliding' : 'is-scrubbing');
|
||||||
|
if (edge !== 'both') {
|
||||||
|
const handle =
|
||||||
|
edge === 'start'
|
||||||
|
? ctx.dom.mediaTimingReviewStartHandle
|
||||||
|
: ctx.dom.mediaTimingReviewEndHandle;
|
||||||
|
handle.focus();
|
||||||
|
if (grabOffset === 0) applyDrag(event.clientX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag(event: PointerEvent): void {
|
||||||
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||||
|
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
||||||
|
if (track.hasPointerCapture(event.pointerId)) track.releasePointerCapture(event.pointerId);
|
||||||
|
drag = null;
|
||||||
|
ctx.dom.mediaTimingReviewModal.classList.remove('is-scrubbing', 'is-sliding');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDrag(): void {
|
||||||
|
drag = null;
|
||||||
|
ctx.dom.mediaTimingReviewModal.classList.remove('is-scrubbing', 'is-sliding');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdgeKeydown(event: KeyboardEvent, edge: 'start' | 'end'): void {
|
||||||
|
const step = event.shiftKey ? COARSE_ADJUST_SECONDS : FINE_ADJUST_SECONDS;
|
||||||
|
const current = edge === 'start' ? selectionStart : selectionEnd;
|
||||||
|
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') setEdge(edge, current - step);
|
||||||
|
else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') setEdge(edge, current + step);
|
||||||
|
else if (event.key === 'Home') setEdge(edge, timelineStart);
|
||||||
|
else if (event.key === 'End') setEdge(edge, timelineEnd);
|
||||||
|
else return;
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEditor(): void {
|
||||||
|
ctx.dom.mediaTimingReviewCancelStep.classList.add('hidden');
|
||||||
|
ctx.dom.mediaTimingReviewEditor.classList.remove('hidden');
|
||||||
|
ctx.dom.mediaTimingReviewCancel.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestCancel(): void {
|
||||||
|
if (!ctx.state.mediaTimingReviewModalOpen || !payload || resolveInFlight) return;
|
||||||
|
stopPreview();
|
||||||
|
ctx.dom.mediaTimingReviewEditor.classList.add('hidden');
|
||||||
|
ctx.dom.mediaTimingReviewCancel.classList.add('hidden');
|
||||||
|
ctx.dom.mediaTimingReviewCancelStep.classList.remove('hidden');
|
||||||
|
ctx.dom.mediaTimingReviewCancelMessage.textContent =
|
||||||
|
payload.noteId !== undefined
|
||||||
|
? 'Keep editing, finish this card with its original timing, or delete the card.'
|
||||||
|
: 'Keep editing, finish this card with its original timing, or do not create it.';
|
||||||
|
ctx.dom.mediaTimingReviewDiscard.textContent =
|
||||||
|
payload.noteId !== undefined ? 'Delete card' : "Don't create card";
|
||||||
|
ctx.dom.mediaTimingReviewCancelBack.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeResolvedReview(): void {
|
||||||
|
if (!ctx.state.mediaTimingReviewModalOpen) return;
|
||||||
|
cancelDrag();
|
||||||
|
clearPreviewTimer();
|
||||||
|
if (waveformTimer !== null) clearTimeout(waveformTimer);
|
||||||
|
waveformTimer = null;
|
||||||
|
waveformSequence += 1;
|
||||||
|
previewPlaying = false;
|
||||||
|
ctx.state.mediaTimingReviewModalOpen = false;
|
||||||
|
ctx.dom.mediaTimingReviewModal.classList.add('hidden');
|
||||||
|
ctx.dom.mediaTimingReviewModal.setAttribute('aria-hidden', 'true');
|
||||||
|
focus.detach();
|
||||||
|
window.electronAPI.notifyOverlayModalClosed('media-timing-review');
|
||||||
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
|
payload = null;
|
||||||
|
if (!options.modalStateReader.isAnyModalOpen()) {
|
||||||
|
ctx.dom.overlay.classList.remove('interactive');
|
||||||
|
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||||
|
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveReview(decision: MediaTimingReviewDecision): Promise<void> {
|
||||||
|
if (!payload || resolveInFlight) return;
|
||||||
|
resolveInFlight = true;
|
||||||
|
stopPreview();
|
||||||
|
const controls = ctx.dom.mediaTimingReviewModal.querySelectorAll<HTMLButtonElement>('button');
|
||||||
|
controls.forEach((button) => {
|
||||||
|
button.disabled = true;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.resolveMediaTimingReview({
|
||||||
|
reviewId: payload.reviewId,
|
||||||
|
decision,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
setStatus(result.message ?? 'The timing review could not be resolved.', true);
|
||||||
|
showEditor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeResolvedReview();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||||
|
showEditor();
|
||||||
|
} finally {
|
||||||
|
resolveInFlight = false;
|
||||||
|
controls.forEach((button) => {
|
||||||
|
button.disabled = false;
|
||||||
|
});
|
||||||
|
renderSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePreview(): Promise<void> {
|
||||||
|
if (!payload || resolveInFlight) return;
|
||||||
|
if (previewPlaying) {
|
||||||
|
stopPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestId = previewRequest.begin();
|
||||||
|
if (requestId === null) return;
|
||||||
|
const requestedReviewId = payload.reviewId;
|
||||||
|
setStatus('Starting audio preview...');
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.previewMediaTimingReview({
|
||||||
|
reviewId: requestedReviewId,
|
||||||
|
startTime: selectionStart,
|
||||||
|
endTime: selectionEnd,
|
||||||
|
});
|
||||||
|
if (!previewRequest.isCurrent(requestId) || payload?.reviewId !== requestedReviewId) {
|
||||||
|
if (result.ok && !previewRequest.isInFlight() && !previewPlaying) {
|
||||||
|
void window.electronAPI.stopMediaTimingReviewPreview(requestedReviewId).catch(() => {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
setStatus(result.message ?? 'Audio preview is unavailable.', true);
|
||||||
|
setPreviewPlaying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus('Previewing in the hidden audio player.');
|
||||||
|
setPreviewPlaying(true);
|
||||||
|
} catch (error) {
|
||||||
|
if (!previewRequest.isCurrent(requestId) || payload?.reviewId !== requestedReviewId) return;
|
||||||
|
setStatus(
|
||||||
|
`Audio preview unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
setPreviewPlaying(false);
|
||||||
|
} finally {
|
||||||
|
previewRequest.finish(requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMediaTimingReviewModal(nextPayload: MediaTimingReviewOpenPayload): void {
|
||||||
|
previewRequest.invalidate();
|
||||||
|
cancelDrag();
|
||||||
|
payload = nextPayload;
|
||||||
|
selectionStart = nextPayload.selectionStartTime;
|
||||||
|
selectionEnd = nextPayload.selectionEndTime;
|
||||||
|
timelineStart = nextPayload.timelineStartTime;
|
||||||
|
timelineEnd = nextPayload.timelineEndTime;
|
||||||
|
resolveInFlight = false;
|
||||||
|
setPreviewPlaying(false);
|
||||||
|
ctx.dom.mediaTimingReviewKind.textContent =
|
||||||
|
nextPayload.kind === 'word'
|
||||||
|
? 'Word card'
|
||||||
|
: nextPayload.kind === 'audio'
|
||||||
|
? 'Audio card'
|
||||||
|
: 'Sentence card';
|
||||||
|
ctx.dom.mediaTimingReviewKind.dataset.kind = nextPayload.kind;
|
||||||
|
ctx.dom.mediaTimingReviewText.textContent = nextPayload.text;
|
||||||
|
ctx.dom.mediaTimingReviewDiscard.textContent =
|
||||||
|
nextPayload.noteId !== undefined ? 'Delete card' : "Don't create card";
|
||||||
|
setStatus('');
|
||||||
|
showEditor();
|
||||||
|
renderSelection();
|
||||||
|
ctx.state.mediaTimingReviewModalOpen = true;
|
||||||
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
|
ctx.dom.overlay.classList.add('interactive');
|
||||||
|
if (ctx.platform.shouldToggleMouseIgnore) window.electronAPI.setIgnoreMouseEvents(false);
|
||||||
|
ctx.dom.mediaTimingReviewModal.classList.remove('hidden');
|
||||||
|
ctx.dom.mediaTimingReviewModal.setAttribute('aria-hidden', 'false');
|
||||||
|
window.electronAPI.notifyOverlayModalOpened('media-timing-review');
|
||||||
|
focus.attach();
|
||||||
|
focus.requestOverlayFocus();
|
||||||
|
window.focus();
|
||||||
|
focus.enforceModalFocus();
|
||||||
|
queueWaveformLoad();
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandTimeline(direction: 'earlier' | 'later'): void {
|
||||||
|
if (!payload) return;
|
||||||
|
if (direction === 'earlier') {
|
||||||
|
timelineStart = Math.max(0, timelineStart - TIMELINE_EXPANSION_SECONDS);
|
||||||
|
} else {
|
||||||
|
timelineEnd = Math.min(
|
||||||
|
payload.mediaDuration ?? Number.POSITIVE_INFINITY,
|
||||||
|
timelineEnd + TIMELINE_EXPANSION_SECONDS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
renderSelection();
|
||||||
|
queueWaveformLoad(120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMediaTimingReviewKeydown(event: KeyboardEvent): boolean {
|
||||||
|
if (!ctx.state.mediaTimingReviewModalOpen) return false;
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!ctx.dom.mediaTimingReviewCancelStep.classList.contains('hidden')) showEditor();
|
||||||
|
else requestCancel();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.code === 'Space' &&
|
||||||
|
event.target instanceof Element &&
|
||||||
|
!event.target.closest('button')
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
void togglePreview();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.key === 'Enter' &&
|
||||||
|
ctx.dom.mediaTimingReviewCancelStep.classList.contains('hidden') &&
|
||||||
|
!(event.target instanceof Element && event.target.closest('button'))
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
void resolveReview({ action: 'confirm', startTime: selectionStart, endTime: selectionEnd });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireDomEvents(): void {
|
||||||
|
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
||||||
|
track.addEventListener('pointerdown', beginDrag);
|
||||||
|
track.addEventListener('pointermove', (event) => {
|
||||||
|
if (drag?.pointerId === event.pointerId) applyDrag(event.clientX);
|
||||||
|
});
|
||||||
|
track.addEventListener('pointerup', endDrag);
|
||||||
|
track.addEventListener('pointercancel', endDrag);
|
||||||
|
ctx.dom.mediaTimingReviewStartHandle.addEventListener('keydown', (event) =>
|
||||||
|
handleEdgeKeydown(event, 'start'),
|
||||||
|
);
|
||||||
|
ctx.dom.mediaTimingReviewEndHandle.addEventListener('keydown', (event) =>
|
||||||
|
handleEdgeKeydown(event, 'end'),
|
||||||
|
);
|
||||||
|
ctx.dom.mediaTimingReviewShowEarlier.addEventListener('click', () => expandTimeline('earlier'));
|
||||||
|
ctx.dom.mediaTimingReviewShowLater.addEventListener('click', () => expandTimeline('later'));
|
||||||
|
ctx.dom.mediaTimingReviewStartBack.addEventListener('click', () =>
|
||||||
|
updateSelection(selectionStart - FINE_ADJUST_SECONDS, selectionEnd),
|
||||||
|
);
|
||||||
|
ctx.dom.mediaTimingReviewStartForward.addEventListener('click', () =>
|
||||||
|
updateSelection(selectionStart + FINE_ADJUST_SECONDS, selectionEnd),
|
||||||
|
);
|
||||||
|
ctx.dom.mediaTimingReviewEndBack.addEventListener('click', () =>
|
||||||
|
updateSelection(selectionStart, selectionEnd - FINE_ADJUST_SECONDS),
|
||||||
|
);
|
||||||
|
ctx.dom.mediaTimingReviewEndForward.addEventListener('click', () =>
|
||||||
|
updateSelection(selectionStart, selectionEnd + FINE_ADJUST_SECONDS),
|
||||||
|
);
|
||||||
|
ctx.dom.mediaTimingReviewPlay.addEventListener('click', () => void togglePreview());
|
||||||
|
ctx.dom.mediaTimingReviewReset.addEventListener('click', () => {
|
||||||
|
if (!payload) return;
|
||||||
|
updateSelection(payload.selectionStartTime, payload.selectionEndTime);
|
||||||
|
});
|
||||||
|
ctx.dom.mediaTimingReviewCancel.addEventListener('click', requestCancel);
|
||||||
|
ctx.dom.mediaTimingReviewCancelBack.addEventListener('click', showEditor);
|
||||||
|
ctx.dom.mediaTimingReviewUseOriginal.addEventListener(
|
||||||
|
'click',
|
||||||
|
() => void resolveReview({ action: 'use-original' }),
|
||||||
|
);
|
||||||
|
ctx.dom.mediaTimingReviewDiscard.addEventListener(
|
||||||
|
'click',
|
||||||
|
() => void resolveReview({ action: 'discard' }),
|
||||||
|
);
|
||||||
|
ctx.dom.mediaTimingReviewConfirm.addEventListener(
|
||||||
|
'click',
|
||||||
|
() =>
|
||||||
|
void resolveReview({ action: 'confirm', startTime: selectionStart, endTime: selectionEnd }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openMediaTimingReviewModal,
|
||||||
|
requestCancel,
|
||||||
|
handleMediaTimingReviewKeydown,
|
||||||
|
wireDomEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ import { createCharacterDictionaryModal } from './modals/character-dictionary.js
|
|||||||
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
||||||
import { createSubsyncModal } from './modals/subsync.js';
|
import { createSubsyncModal } from './modals/subsync.js';
|
||||||
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
||||||
|
import { createMediaTimingReviewModal } from './modals/media-timing-review.js';
|
||||||
import { createPositioningController } from './positioning.js';
|
import { createPositioningController } from './positioning.js';
|
||||||
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
||||||
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
|
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
|
||||||
@@ -114,6 +115,12 @@ const modalDescriptors = [
|
|||||||
close: () => youtubePickerModal.closeYoutubePickerModal(),
|
close: () => youtubePickerModal.closeYoutubePickerModal(),
|
||||||
suppressesSubtitles: true,
|
suppressesSubtitles: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'media-timing-review',
|
||||||
|
isOpen: () => ctx.state.mediaTimingReviewModalOpen,
|
||||||
|
close: () => mediaTimingReviewModal.requestCancel(),
|
||||||
|
suppressesSubtitles: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'playlist-browser',
|
id: 'playlist-browser',
|
||||||
isOpen: () => ctx.state.playlistBrowserModalOpen,
|
isOpen: () => ctx.state.playlistBrowserModalOpen,
|
||||||
@@ -265,6 +272,10 @@ const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
|
|||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
});
|
});
|
||||||
|
const mediaTimingReviewModal = createMediaTimingReviewModal(ctx, {
|
||||||
|
modalStateReader: { isAnyModalOpen },
|
||||||
|
syncSettingsModalSubtitleSuppression,
|
||||||
|
});
|
||||||
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||||
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
||||||
@@ -273,6 +284,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
|||||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||||
handleTsukihimeKeydown: tsukihimeModal.handleTsukihimeKeydown,
|
handleTsukihimeKeydown: tsukihimeModal.handleTsukihimeKeydown,
|
||||||
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
|
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
|
||||||
|
handleMediaTimingReviewKeydown: mediaTimingReviewModal.handleMediaTimingReviewKeydown,
|
||||||
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
|
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
|
||||||
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
||||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||||
@@ -574,6 +586,11 @@ function registerModalOpenHandlers(): void {
|
|||||||
youtubePickerModal.openYoutubePickerModal(payload);
|
youtubePickerModal.openYoutubePickerModal(payload);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
window.electronAPI.onOpenMediaTimingReview((payload) => {
|
||||||
|
runGuarded('media-timing-review:open', () => {
|
||||||
|
mediaTimingReviewModal.openMediaTimingReviewModal(payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
window.electronAPI.onOpenPlaylistBrowser(() => {
|
window.electronAPI.onOpenPlaylistBrowser(() => {
|
||||||
runGuardedAsync('playlist-browser:open', async () => {
|
runGuardedAsync('playlist-browser:open', async () => {
|
||||||
await playlistBrowserModal.openPlaylistBrowserModal();
|
await playlistBrowserModal.openPlaylistBrowserModal();
|
||||||
@@ -805,6 +822,7 @@ async function init(): Promise<void> {
|
|||||||
jimakuModal.wireDomEvents();
|
jimakuModal.wireDomEvents();
|
||||||
tsukihimeModal.wireDomEvents();
|
tsukihimeModal.wireDomEvents();
|
||||||
youtubePickerModal.wireDomEvents();
|
youtubePickerModal.wireDomEvents();
|
||||||
|
mediaTimingReviewModal.wireDomEvents();
|
||||||
playlistBrowserModal.wireDomEvents();
|
playlistBrowserModal.wireDomEvents();
|
||||||
kikuModal.wireDomEvents();
|
kikuModal.wireDomEvents();
|
||||||
runtimeOptionsModal.wireDomEvents();
|
runtimeOptionsModal.wireDomEvents();
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export type RendererState = {
|
|||||||
youtubePickerSecondaryTrackId: string | null;
|
youtubePickerSecondaryTrackId: string | null;
|
||||||
youtubePickerStatus: string;
|
youtubePickerStatus: string;
|
||||||
|
|
||||||
|
mediaTimingReviewModalOpen: boolean;
|
||||||
|
|
||||||
kikuModalOpen: boolean;
|
kikuModalOpen: boolean;
|
||||||
kikuSelectedCard: 1 | 2;
|
kikuSelectedCard: 1 | 2;
|
||||||
kikuOriginalData: KikuDuplicateCardInfo | null;
|
kikuOriginalData: KikuDuplicateCardInfo | null;
|
||||||
@@ -194,6 +196,8 @@ export function createRendererState(): RendererState {
|
|||||||
youtubePickerSecondaryTrackId: null,
|
youtubePickerSecondaryTrackId: null,
|
||||||
youtubePickerStatus: '',
|
youtubePickerStatus: '',
|
||||||
|
|
||||||
|
mediaTimingReviewModalOpen: false,
|
||||||
|
|
||||||
kikuModalOpen: false,
|
kikuModalOpen: false,
|
||||||
kikuSelectedCard: 1,
|
kikuSelectedCard: 1,
|
||||||
kikuOriginalData: null,
|
kikuOriginalData: null,
|
||||||
|
|||||||
@@ -1323,6 +1323,701 @@ body:focus-visible,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Media timing review uses the Catppuccin Macchiato palette without opacity-shifted colors. */
|
||||||
|
.media-timing-review-modal {
|
||||||
|
background: rgba(24, 25, 38, 0.76);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pointer capture keeps events on the track, so the cursor is forced from the modal root. */
|
||||||
|
.media-timing-review-modal.is-scrubbing,
|
||||||
|
.media-timing-review-modal.is-scrubbing * {
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-modal.is-sliding,
|
||||||
|
.media-timing-review-modal.is-sliding * {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-content {
|
||||||
|
width: min(720px, calc(100vw - 32px));
|
||||||
|
max-height: min(700px, calc(100vh - 32px));
|
||||||
|
overflow: auto;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--ctp-base);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
box-shadow:
|
||||||
|
0 30px 90px rgba(24, 25, 38, 0.8),
|
||||||
|
0 0 0 1px rgba(183, 189, 248, 0.07) inset;
|
||||||
|
animation: media-timing-review-enter 180ms cubic-bezier(0.2, 0.9, 0.25, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes media-timing-review-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px) scale(0.985);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-header {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 2;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 16px 20px 13px;
|
||||||
|
border-bottom: 1px solid var(--ctp-surface0);
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-heading {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-title {
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-kind {
|
||||||
|
--kind-accent: var(--ctp-teal);
|
||||||
|
|
||||||
|
padding: 3px 9px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--kind-accent) 42%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--kind-accent) 13%, transparent);
|
||||||
|
color: var(--kind-accent);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-kind[data-kind='word'] {
|
||||||
|
--kind-accent: var(--ctp-mauve);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-kind[data-kind='audio'] {
|
||||||
|
--kind-accent: var(--ctp-sky);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-editor {
|
||||||
|
padding: 14px 20px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-text {
|
||||||
|
max-height: 88px;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-left: 3px solid var(--ctp-mauve);
|
||||||
|
border-radius: 0 10px 10px 0;
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
color: var(--ctp-subtext1);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 560;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-readout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-readout > div {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-readout > div + div {
|
||||||
|
border-left: 1px solid var(--ctp-surface0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-readout span {
|
||||||
|
color: var(--ctp-overlay1);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-readout strong {
|
||||||
|
color: var(--ctp-lavender);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-readout .media-timing-review-duration {
|
||||||
|
align-items: center;
|
||||||
|
min-width: 128px;
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-readout > div:last-child {
|
||||||
|
align-items: flex-end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-readout .media-timing-review-duration strong {
|
||||||
|
color: var(--ctp-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-timeline-shell {
|
||||||
|
padding: 10px 12px 8px;
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-timeline-labels,
|
||||||
|
.media-timing-review-expand-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--ctp-overlay1);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-timeline-labels span:first-child,
|
||||||
|
.media-timing-review-timeline-labels span:last-child {
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-track {
|
||||||
|
--selection-start: 20%;
|
||||||
|
--selection-end: 80%;
|
||||||
|
--original-start: 25%;
|
||||||
|
--original-end: 75%;
|
||||||
|
--playhead-duration: 1s;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
height: 76px;
|
||||||
|
margin: 8px 0 9px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--ctp-surface2);
|
||||||
|
border-radius: 10px;
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--ctp-surface1), var(--ctp-surface1)) center / 100% 1px no-repeat,
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0,
|
||||||
|
transparent calc(10% - 1px),
|
||||||
|
color-mix(in srgb, var(--ctp-surface1) 65%, transparent) calc(10% - 1px),
|
||||||
|
color-mix(in srgb, var(--ctp-surface1) 65%, transparent) 10%
|
||||||
|
),
|
||||||
|
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 selection scrim so adjacent dialogue reads as outside the mined subtitle. */
|
||||||
|
.media-timing-review-original-range {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 4;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: var(--original-start);
|
||||||
|
right: calc(100% - var(--original-end));
|
||||||
|
min-width: 1px;
|
||||||
|
background: color-mix(in srgb, var(--ctp-peach) 11%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ctp-peach) 28%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-original-boundary {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--ctp-peach);
|
||||||
|
box-shadow: 0 0 8px color-mix(in srgb, var(--ctp-peach) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-original-boundary.is-start {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-original-boundary.is-end {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-original-boundary::after {
|
||||||
|
position: absolute;
|
||||||
|
padding: 3px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--ctp-peach);
|
||||||
|
color: var(--ctp-crust);
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-original-boundary.is-start::after {
|
||||||
|
top: 5px;
|
||||||
|
left: 5px;
|
||||||
|
content: 'line start';
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-original-boundary.is-end::after {
|
||||||
|
right: 5px;
|
||||||
|
bottom: 5px;
|
||||||
|
content: 'line end';
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-track.is-loading::after {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
inset: 0;
|
||||||
|
content: '';
|
||||||
|
background: linear-gradient(
|
||||||
|
105deg,
|
||||||
|
transparent 20%,
|
||||||
|
rgba(138, 173, 244, 0.16) 44%,
|
||||||
|
rgba(183, 189, 248, 0.22) 50%,
|
||||||
|
transparent 76%
|
||||||
|
);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
animation: media-timing-waveform-loading 1.1s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-track.is-waveform-unavailable .media-timing-review-waveform {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes media-timing-waveform-loading {
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The oversized shadow spread dims everything outside the clip; the track clips it. */
|
||||||
|
.media-timing-review-selected-range {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
inset: 0 calc(100% - var(--selection-end)) 0 var(--selection-start);
|
||||||
|
border-inline: 1px solid color-mix(in srgb, var(--ctp-teal) 65%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--ctp-teal) 9%, transparent);
|
||||||
|
box-shadow: 0 0 0 2000px color-mix(in srgb, var(--ctp-crust) 62%, transparent);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-playhead {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: var(--selection-start);
|
||||||
|
width: 2px;
|
||||||
|
background: var(--ctp-yellow);
|
||||||
|
box-shadow: 0 0 12px color-mix(in srgb, var(--ctp-yellow) 75%, transparent);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-track.is-previewing .media-timing-review-playhead {
|
||||||
|
opacity: 1;
|
||||||
|
animation: media-timing-review-playhead var(--playhead-duration) linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes media-timing-review-playhead {
|
||||||
|
from {
|
||||||
|
left: var(--selection-start);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
left: var(--selection-end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-handle {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 6;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 14px;
|
||||||
|
border: 1px solid var(--ctp-teal);
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--ctp-teal),
|
||||||
|
color-mix(in srgb, var(--ctp-teal) 74%, var(--ctp-crust))
|
||||||
|
);
|
||||||
|
cursor: ew-resize;
|
||||||
|
touch-action: none;
|
||||||
|
transition: box-shadow 130ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Widens the grab area past the visible bracket without moving the clip edge. */
|
||||||
|
.media-timing-review-handle::before {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 -7px;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-handle::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 6px;
|
||||||
|
height: 18px;
|
||||||
|
content: '';
|
||||||
|
border-inline: 1px solid color-mix(in srgb, var(--ctp-crust) 55%, transparent);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-handle-start {
|
||||||
|
left: var(--selection-start);
|
||||||
|
border-radius: 5px 0 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-handle-end {
|
||||||
|
left: var(--selection-end);
|
||||||
|
border-radius: 0 5px 5px 0;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-handle:hover {
|
||||||
|
box-shadow: 0 0 16px color-mix(in srgb, var(--ctp-teal) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-handle:focus-visible {
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
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 {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-expand-button,
|
||||||
|
.media-timing-review-quiet-button,
|
||||||
|
.media-timing-review-original-button,
|
||||||
|
.media-timing-review-discard-button,
|
||||||
|
.media-timing-review-play-button,
|
||||||
|
.media-timing-review-confirm-button,
|
||||||
|
.media-timing-review-fine-control button {
|
||||||
|
border: 1px solid var(--ctp-surface2);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 720;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 130ms ease,
|
||||||
|
border-color 130ms ease,
|
||||||
|
transform 130ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-expand-button {
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: var(--ctp-sky);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-expand-button:disabled,
|
||||||
|
.media-timing-review-content button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-content button:not(:disabled):hover {
|
||||||
|
border-color: var(--ctp-lavender);
|
||||||
|
background: var(--ctp-surface1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-content button:not(:disabled):active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-content button:focus-visible {
|
||||||
|
outline: 2px solid var(--ctp-yellow);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-fine-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-fine-control {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--ctp-surface0);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
color: var(--ctp-subtext1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-fine-control span {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-fine-control button {
|
||||||
|
min-width: 58px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-status {
|
||||||
|
min-height: 21px;
|
||||||
|
padding-top: 8px;
|
||||||
|
color: var(--ctp-teal);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 620;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-status.is-error {
|
||||||
|
color: var(--ctp-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-footer,
|
||||||
|
.media-timing-review-preview-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-footer {
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-play-button,
|
||||||
|
.media-timing-review-confirm-button,
|
||||||
|
.media-timing-review-quiet-button,
|
||||||
|
.media-timing-review-original-button,
|
||||||
|
.media-timing-review-discard-button {
|
||||||
|
padding: 9px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-play-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
border-color: var(--ctp-teal);
|
||||||
|
background: var(--ctp-teal);
|
||||||
|
color: var(--ctp-crust);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-play-glyph {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-block: 5px solid transparent;
|
||||||
|
border-left: 9px solid currentcolor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-play-button.is-playing {
|
||||||
|
border-color: var(--ctp-yellow);
|
||||||
|
background: var(--ctp-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-play-button.is-playing .media-timing-review-play-glyph {
|
||||||
|
width: 9px;
|
||||||
|
height: 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: currentcolor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-confirm-button {
|
||||||
|
border-color: var(--ctp-blue);
|
||||||
|
background: var(--ctp-blue);
|
||||||
|
color: var(--ctp-crust);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-original-button {
|
||||||
|
border-color: var(--ctp-yellow);
|
||||||
|
color: var(--ctp-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-discard-button {
|
||||||
|
border-color: var(--ctp-red);
|
||||||
|
background: var(--ctp-red);
|
||||||
|
color: var(--ctp-crust);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-hints {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 16px;
|
||||||
|
padding-top: 14px;
|
||||||
|
color: var(--ctp-overlay1);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-hints kbd {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 15px;
|
||||||
|
margin-right: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 750;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-cancel-step {
|
||||||
|
min-height: 300px;
|
||||||
|
padding: 44px 38px 34px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-cancel-mark {
|
||||||
|
display: grid;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px solid var(--ctp-yellow);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--ctp-yellow) 12%, transparent);
|
||||||
|
color: var(--ctp-yellow);
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-cancel-step h2 {
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font-size: 21px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-cancel-step p {
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 8px auto 24px;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-cancel-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.media-timing-review-content {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-track.is-loading::after,
|
||||||
|
.media-timing-review-track.is-previewing .media-timing-review-playhead {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.media-timing-review-content {
|
||||||
|
width: calc(100vw - 18px);
|
||||||
|
max-height: calc(100vh - 18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-header,
|
||||||
|
.media-timing-review-editor {
|
||||||
|
padding-right: 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 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-footer {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-timing-review-preview-actions > *,
|
||||||
|
.media-timing-review-confirm-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body.subtitle-sidebar-embedded-open #subtitleContainer {
|
body.subtitle-sidebar-embedded-open #subtitleContainer {
|
||||||
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
||||||
transform: translateX(calc(var(--subtitle-sidebar-reserved-width) * -0.5));
|
transform: translateX(calc(var(--subtitle-sidebar-reserved-width) * -0.5));
|
||||||
|
|||||||
@@ -44,6 +44,39 @@ export type RendererDom = {
|
|||||||
youtubePickerStatus: HTMLDivElement;
|
youtubePickerStatus: HTMLDivElement;
|
||||||
youtubePickerTracks: HTMLUListElement;
|
youtubePickerTracks: HTMLUListElement;
|
||||||
|
|
||||||
|
mediaTimingReviewModal: HTMLDivElement;
|
||||||
|
mediaTimingReviewKind: HTMLDivElement;
|
||||||
|
mediaTimingReviewText: HTMLElement;
|
||||||
|
mediaTimingReviewStartValue: HTMLElement;
|
||||||
|
mediaTimingReviewEndValue: HTMLElement;
|
||||||
|
mediaTimingReviewDuration: HTMLElement;
|
||||||
|
mediaTimingReviewTimelineStart: HTMLElement;
|
||||||
|
mediaTimingReviewTimelineEnd: HTMLElement;
|
||||||
|
mediaTimingReviewSelectionTrack: HTMLDivElement;
|
||||||
|
mediaTimingReviewWaveformLabel: HTMLElement;
|
||||||
|
mediaTimingReviewWaveformPath: SVGPathElement;
|
||||||
|
mediaTimingReviewSelectedRange: HTMLDivElement;
|
||||||
|
mediaTimingReviewStartHandle: HTMLDivElement;
|
||||||
|
mediaTimingReviewEndHandle: HTMLDivElement;
|
||||||
|
mediaTimingReviewShowEarlier: HTMLButtonElement;
|
||||||
|
mediaTimingReviewShowLater: HTMLButtonElement;
|
||||||
|
mediaTimingReviewStartBack: HTMLButtonElement;
|
||||||
|
mediaTimingReviewStartForward: HTMLButtonElement;
|
||||||
|
mediaTimingReviewEndBack: HTMLButtonElement;
|
||||||
|
mediaTimingReviewEndForward: HTMLButtonElement;
|
||||||
|
mediaTimingReviewPlay: HTMLButtonElement;
|
||||||
|
mediaTimingReviewPlayLabel: HTMLElement;
|
||||||
|
mediaTimingReviewReset: HTMLButtonElement;
|
||||||
|
mediaTimingReviewCancel: HTMLButtonElement;
|
||||||
|
mediaTimingReviewConfirm: HTMLButtonElement;
|
||||||
|
mediaTimingReviewStatus: HTMLDivElement;
|
||||||
|
mediaTimingReviewEditor: HTMLDivElement;
|
||||||
|
mediaTimingReviewCancelStep: HTMLDivElement;
|
||||||
|
mediaTimingReviewCancelMessage: HTMLParagraphElement;
|
||||||
|
mediaTimingReviewCancelBack: HTMLButtonElement;
|
||||||
|
mediaTimingReviewUseOriginal: HTMLButtonElement;
|
||||||
|
mediaTimingReviewDiscard: HTMLButtonElement;
|
||||||
|
|
||||||
kikuModal: HTMLDivElement;
|
kikuModal: HTMLDivElement;
|
||||||
kikuCard1: HTMLDivElement;
|
kikuCard1: HTMLDivElement;
|
||||||
kikuCard2: HTMLDivElement;
|
kikuCard2: HTMLDivElement;
|
||||||
@@ -145,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}`);
|
||||||
}
|
}
|
||||||
@@ -204,6 +237,63 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
youtubePickerStatus: getRequiredElement<HTMLDivElement>('youtubePickerStatus'),
|
youtubePickerStatus: getRequiredElement<HTMLDivElement>('youtubePickerStatus'),
|
||||||
youtubePickerTracks: getRequiredElement<HTMLUListElement>('youtubePickerTracks'),
|
youtubePickerTracks: getRequiredElement<HTMLUListElement>('youtubePickerTracks'),
|
||||||
|
|
||||||
|
mediaTimingReviewModal: getRequiredElement<HTMLDivElement>('mediaTimingReviewModal'),
|
||||||
|
mediaTimingReviewKind: getRequiredElement<HTMLDivElement>('mediaTimingReviewKind'),
|
||||||
|
mediaTimingReviewText: getRequiredElement<HTMLElement>('mediaTimingReviewText'),
|
||||||
|
mediaTimingReviewStartValue: getRequiredElement<HTMLElement>('mediaTimingReviewStartValue'),
|
||||||
|
mediaTimingReviewEndValue: getRequiredElement<HTMLElement>('mediaTimingReviewEndValue'),
|
||||||
|
mediaTimingReviewDuration: getRequiredElement<HTMLElement>('mediaTimingReviewDuration'),
|
||||||
|
mediaTimingReviewTimelineStart: getRequiredElement<HTMLElement>(
|
||||||
|
'mediaTimingReviewTimelineStart',
|
||||||
|
),
|
||||||
|
mediaTimingReviewTimelineEnd: getRequiredElement<HTMLElement>('mediaTimingReviewTimelineEnd'),
|
||||||
|
mediaTimingReviewSelectionTrack: getRequiredElement<HTMLDivElement>(
|
||||||
|
'mediaTimingReviewSelectionTrack',
|
||||||
|
),
|
||||||
|
mediaTimingReviewWaveformLabel: getRequiredElement<HTMLElement>(
|
||||||
|
'mediaTimingReviewWaveformLabel',
|
||||||
|
),
|
||||||
|
mediaTimingReviewWaveformPath: getRequiredElement<SVGPathElement>(
|
||||||
|
'mediaTimingReviewWaveformPath',
|
||||||
|
),
|
||||||
|
mediaTimingReviewSelectedRange: getRequiredElement<HTMLDivElement>(
|
||||||
|
'mediaTimingReviewSelectedRange',
|
||||||
|
),
|
||||||
|
mediaTimingReviewStartHandle: getRequiredElement<HTMLDivElement>(
|
||||||
|
'mediaTimingReviewStartHandle',
|
||||||
|
),
|
||||||
|
mediaTimingReviewEndHandle: getRequiredElement<HTMLDivElement>('mediaTimingReviewEndHandle'),
|
||||||
|
mediaTimingReviewShowEarlier: getRequiredElement<HTMLButtonElement>(
|
||||||
|
'mediaTimingReviewShowEarlier',
|
||||||
|
),
|
||||||
|
mediaTimingReviewShowLater: getRequiredElement<HTMLButtonElement>('mediaTimingReviewShowLater'),
|
||||||
|
mediaTimingReviewStartBack: getRequiredElement<HTMLButtonElement>('mediaTimingReviewStartBack'),
|
||||||
|
mediaTimingReviewStartForward: getRequiredElement<HTMLButtonElement>(
|
||||||
|
'mediaTimingReviewStartForward',
|
||||||
|
),
|
||||||
|
mediaTimingReviewEndBack: getRequiredElement<HTMLButtonElement>('mediaTimingReviewEndBack'),
|
||||||
|
mediaTimingReviewEndForward: getRequiredElement<HTMLButtonElement>(
|
||||||
|
'mediaTimingReviewEndForward',
|
||||||
|
),
|
||||||
|
mediaTimingReviewPlay: getRequiredElement<HTMLButtonElement>('mediaTimingReviewPlay'),
|
||||||
|
mediaTimingReviewPlayLabel: getRequiredElement<HTMLElement>('mediaTimingReviewPlayLabel'),
|
||||||
|
mediaTimingReviewReset: getRequiredElement<HTMLButtonElement>('mediaTimingReviewReset'),
|
||||||
|
mediaTimingReviewCancel: getRequiredElement<HTMLButtonElement>('mediaTimingReviewCancel'),
|
||||||
|
mediaTimingReviewConfirm: getRequiredElement<HTMLButtonElement>('mediaTimingReviewConfirm'),
|
||||||
|
mediaTimingReviewStatus: getRequiredElement<HTMLDivElement>('mediaTimingReviewStatus'),
|
||||||
|
mediaTimingReviewEditor: getRequiredElement<HTMLDivElement>('mediaTimingReviewEditor'),
|
||||||
|
mediaTimingReviewCancelStep: getRequiredElement<HTMLDivElement>('mediaTimingReviewCancelStep'),
|
||||||
|
mediaTimingReviewCancelMessage: getRequiredElement<HTMLParagraphElement>(
|
||||||
|
'mediaTimingReviewCancelMessage',
|
||||||
|
),
|
||||||
|
mediaTimingReviewCancelBack: getRequiredElement<HTMLButtonElement>(
|
||||||
|
'mediaTimingReviewCancelBack',
|
||||||
|
),
|
||||||
|
mediaTimingReviewUseOriginal: getRequiredElement<HTMLButtonElement>(
|
||||||
|
'mediaTimingReviewUseOriginal',
|
||||||
|
),
|
||||||
|
mediaTimingReviewDiscard: getRequiredElement<HTMLButtonElement>('mediaTimingReviewDiscard'),
|
||||||
|
|
||||||
kikuModal: getRequiredElement<HTMLDivElement>('kikuFieldGroupingModal'),
|
kikuModal: getRequiredElement<HTMLDivElement>('kikuFieldGroupingModal'),
|
||||||
kikuCard1: getRequiredElement<HTMLDivElement>('kikuCard1'),
|
kikuCard1: getRequiredElement<HTMLDivElement>('kikuCard1'),
|
||||||
kikuCard2: getRequiredElement<HTMLDivElement>('kikuCard2'),
|
kikuCard2: getRequiredElement<HTMLDivElement>('kikuCard2'),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const OVERLAY_HOSTED_MODALS = [
|
|||||||
'jimaku',
|
'jimaku',
|
||||||
'tsukihime',
|
'tsukihime',
|
||||||
'youtube-track-picker',
|
'youtube-track-picker',
|
||||||
|
'media-timing-review',
|
||||||
'playlist-browser',
|
'playlist-browser',
|
||||||
'kiku',
|
'kiku',
|
||||||
'controller-select',
|
'controller-select',
|
||||||
@@ -125,6 +126,10 @@ export const IPC_CHANNELS = {
|
|||||||
syncUiRevealSnapshot: 'sync-ui:reveal-snapshot',
|
syncUiRevealSnapshot: 'sync-ui:reveal-snapshot',
|
||||||
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',
|
||||||
|
mediaTimingReviewWaveform: 'media-timing-review:waveform',
|
||||||
|
mediaTimingReviewStopPreview: 'media-timing-review:stop-preview',
|
||||||
|
mediaTimingReviewResolve: 'media-timing-review:resolve',
|
||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
subtitleSet: 'subtitle:set',
|
subtitleSet: 'subtitle:set',
|
||||||
@@ -142,6 +147,7 @@ export const IPC_CHANNELS = {
|
|||||||
jimakuOpen: 'jimaku:open',
|
jimakuOpen: 'jimaku:open',
|
||||||
tsukihimeOpen: 'tsukihime:open',
|
tsukihimeOpen: 'tsukihime:open',
|
||||||
youtubePickerOpen: 'youtube:picker-open',
|
youtubePickerOpen: 'youtube:picker-open',
|
||||||
|
mediaTimingReviewOpen: 'media-timing-review:open',
|
||||||
youtubePickerCancel: 'youtube:picker-cancel',
|
youtubePickerCancel: 'youtube:picker-cancel',
|
||||||
playlistBrowserOpen: 'playlist-browser:open',
|
playlistBrowserOpen: 'playlist-browser:open',
|
||||||
sessionNumericSelectionStart: 'session:numeric-selection-start',
|
sessionNumericSelectionStart: 'session:numeric-selection-start',
|
||||||
|
|||||||
@@ -11,6 +11,64 @@ export type CardKind = 'sentence' | 'audio' | 'word-and-sentence' | 'click';
|
|||||||
/** Card kind SubMiner flags on word cards; 'none' leaves the flag fields untouched. */
|
/** Card kind SubMiner flags on word cards; 'none' leaves the flag fields untouched. */
|
||||||
export type WordCardKind = CardKind | 'none';
|
export type WordCardKind = CardKind | 'none';
|
||||||
|
|
||||||
|
export type MediaTimingReviewKind = 'word' | 'sentence' | 'audio';
|
||||||
|
|
||||||
|
export interface MediaTimingReviewRequest {
|
||||||
|
kind: MediaTimingReviewKind;
|
||||||
|
text: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
noteId?: number;
|
||||||
|
audioPadding: number;
|
||||||
|
maxMediaDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaTimingReviewDecision =
|
||||||
|
| { action: 'confirm'; startTime: number; endTime: number }
|
||||||
|
| { action: 'use-original' }
|
||||||
|
| { action: 'discard' };
|
||||||
|
|
||||||
|
export interface MediaTimingReviewOpenPayload {
|
||||||
|
reviewId: string;
|
||||||
|
kind: MediaTimingReviewKind;
|
||||||
|
text: string;
|
||||||
|
noteId?: number;
|
||||||
|
originalStartTime: number;
|
||||||
|
originalEndTime: number;
|
||||||
|
selectionStartTime: number;
|
||||||
|
selectionEndTime: number;
|
||||||
|
timelineStartTime: number;
|
||||||
|
timelineEndTime: number;
|
||||||
|
mediaDuration?: number;
|
||||||
|
maxMediaDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaTimingReviewPreviewRequest {
|
||||||
|
reviewId: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaTimingReviewWaveformRequest {
|
||||||
|
reviewId: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaTimingReviewWaveformResult extends MediaTimingReviewActionResult {
|
||||||
|
peaks?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaTimingReviewResolveRequest {
|
||||||
|
reviewId: string;
|
||||||
|
decision: MediaTimingReviewDecision;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaTimingReviewActionResult {
|
||||||
|
ok: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NotificationOptions {
|
export interface NotificationOptions {
|
||||||
body?: string;
|
body?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
@@ -85,6 +143,7 @@ export interface AnkiConnectConfig {
|
|||||||
syncAnimatedImageToWordAudio?: boolean;
|
syncAnimatedImageToWordAudio?: boolean;
|
||||||
normalizeAudio?: boolean;
|
normalizeAudio?: boolean;
|
||||||
mirrorMpvVolume?: boolean;
|
mirrorMpvVolume?: boolean;
|
||||||
|
reviewTiming?: boolean;
|
||||||
audioPadding?: number;
|
audioPadding?: number;
|
||||||
fallbackDuration?: number;
|
fallbackDuration?: number;
|
||||||
maxMediaDuration?: number;
|
maxMediaDuration?: number;
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ export interface ResolvedConfig {
|
|||||||
syncAnimatedImageToWordAudio: boolean;
|
syncAnimatedImageToWordAudio: boolean;
|
||||||
normalizeAudio: boolean;
|
normalizeAudio: boolean;
|
||||||
mirrorMpvVolume: boolean;
|
mirrorMpvVolume: boolean;
|
||||||
|
reviewTiming: boolean;
|
||||||
audioPadding: number;
|
audioPadding: number;
|
||||||
fallbackDuration: number;
|
fallbackDuration: number;
|
||||||
maxMediaDuration: number;
|
maxMediaDuration: number;
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import type {
|
|||||||
KikuFieldGroupingRequestData,
|
KikuFieldGroupingRequestData,
|
||||||
KikuMergePreviewRequest,
|
KikuMergePreviewRequest,
|
||||||
KikuMergePreviewResponse,
|
KikuMergePreviewResponse,
|
||||||
|
MediaTimingReviewActionResult,
|
||||||
|
MediaTimingReviewOpenPayload,
|
||||||
|
MediaTimingReviewPreviewRequest,
|
||||||
|
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';
|
||||||
@@ -516,6 +522,17 @@ export interface ElectronAPI {
|
|||||||
onOpenJimaku: (callback: () => void) => void;
|
onOpenJimaku: (callback: () => void) => void;
|
||||||
onOpenTsukihime: (callback: () => void) => void;
|
onOpenTsukihime: (callback: () => void) => void;
|
||||||
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
|
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
|
||||||
|
onOpenMediaTimingReview: (callback: (payload: MediaTimingReviewOpenPayload) => void) => void;
|
||||||
|
previewMediaTimingReview: (
|
||||||
|
request: MediaTimingReviewPreviewRequest,
|
||||||
|
) => Promise<MediaTimingReviewActionResult>;
|
||||||
|
getMediaTimingReviewWaveform: (
|
||||||
|
request: MediaTimingReviewWaveformRequest,
|
||||||
|
) => Promise<MediaTimingReviewWaveformResult>;
|
||||||
|
stopMediaTimingReviewPreview: (reviewId: string) => Promise<MediaTimingReviewActionResult>;
|
||||||
|
resolveMediaTimingReview: (
|
||||||
|
request: MediaTimingReviewResolveRequest,
|
||||||
|
) => Promise<MediaTimingReviewActionResult>;
|
||||||
onOpenPlaylistBrowser: (callback: () => void) => void;
|
onOpenPlaylistBrowser: (callback: () => void) => void;
|
||||||
onOpenCharacterDictionaryManager: (callback: () => void) => void;
|
onOpenCharacterDictionaryManager: (callback: () => void) => void;
|
||||||
onSubtitleSidebarToggle: (callback: () => void) => void;
|
onSubtitleSidebarToggle: (callback: () => void) => void;
|
||||||
@@ -561,6 +578,7 @@ export interface ElectronAPI {
|
|||||||
| 'jimaku'
|
| 'jimaku'
|
||||||
| 'tsukihime'
|
| 'tsukihime'
|
||||||
| 'youtube-track-picker'
|
| 'youtube-track-picker'
|
||||||
|
| 'media-timing-review'
|
||||||
| 'playlist-browser'
|
| 'playlist-browser'
|
||||||
| 'kiku'
|
| 'kiku'
|
||||||
| 'controller-select'
|
| 'controller-select'
|
||||||
@@ -577,6 +595,7 @@ export interface ElectronAPI {
|
|||||||
| 'jimaku'
|
| 'jimaku'
|
||||||
| 'tsukihime'
|
| 'tsukihime'
|
||||||
| 'youtube-track-picker'
|
| 'youtube-track-picker'
|
||||||
|
| 'media-timing-review'
|
||||||
| 'playlist-browser'
|
| 'playlist-browser'
|
||||||
| 'kiku'
|
| 'kiku'
|
||||||
| 'controller-select'
|
| 'controller-select'
|
||||||
|
|||||||
@@ -243,6 +243,8 @@ export interface SubtitleMiningContext {
|
|||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
capturedAtMs?: number;
|
capturedAtMs?: number;
|
||||||
|
/** Explicit generator padding. Confirmed timing-review ranges set this to zero. */
|
||||||
|
mediaPaddingSeconds?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubtitleHoverTokenPayload {
|
export interface SubtitleHoverTokenPayload {
|
||||||
|
|||||||
Reference in New Issue
Block a user