mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-11 17:16:18 -07:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b58f391b42
|
||
|
|
90a5dcd2af
|
||
|
|
989122c006
|
||
|
|
ad1c1aea1b
|
||
|
|
62d645e577
|
||
|
|
f36e7f6182
|
||
|
|
475f81d96d
|
||
|
|
046ab59da9
|
||
|
|
e91f031faa
|
||
|
|
ec6a106748
|
||
|
|
ca27a4275f
|
||
|
|
256eb5da1b
|
||
|
|
2ad491e95c
|
||
|
|
6fffcc731f | ||
|
|
5cc21113fd | ||
|
|
051140f910 | ||
|
|
6e945f0872 | ||
|
|
c2c25c0da6 | ||
|
|
556de61756
|
+31
-3
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.5 (2026-08-30)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Anki Card Update Progress**: The card-update spinner now stays visible until audio and image updates finish, instead of disappearing early.
|
||||
- **Anki Word-Card Fields**: Word-card enrichment now writes sentence text and audio to the fields configured in AnkiConnect, while the dedicated sentence-card and audio-card actions keep their existing compatible field names.
|
||||
- **Overlapping Subtitles**:
|
||||
- Subtitle lines that start while another line is still on screen now appear alongside it, instead of staying hidden until a track switch or seek.
|
||||
- Subtitles shown at the same time now stack by their authored screen position, with top signs and song lines above bottom dialogue.
|
||||
- Half-size ASS furigana is no longer shown as if it were a dialogue line.
|
||||
- **YouTube Auto Captions**:
|
||||
- Auto-generated captions now follow their intended timing and two-row roll-up layout.
|
||||
- Long speech is paged instead of covering the video with a wall of text.
|
||||
- Explicitly timed sound cues like `[音楽]` no longer cover later dialogue.
|
||||
|
||||
## v0.19.4 (2026-08-25)
|
||||
|
||||
### Added
|
||||
@@ -10,9 +25,22 @@
|
||||
- **Prerelease Release Notes**: Prerelease notes now open with a "Changes since" section listing only what changed versus the previous beta/RC of the same version, above the cumulative highlights, and CI rejects prerelease tags whose committed notes were generated for a different beta/RC.
|
||||
|
||||
### Fixed
|
||||
- **Subtitle & Karaoke Duplication**: Typeset ASS karaoke and animated signs no longer flood the overlay, subtitle sidebar, immersion history, mining, or stats with glyph fragments, per-frame color phases, or repeated animation events. Lines are reconstructed once from their authored text and shown only while actually sung, with original word spacing preserved. Decorative layers (highlight sweeps, glow/shadow copies, symbol-font decoration, particle swarms, hidden or zero-scaled text) stay out of published text, while ordinary repeated dialogue, positioned signs, wrapped lyric rows, and multi-row CC-style blocks still display correctly. Embedded subtitle tracks on network-mounted (SMB/NFS) media are extracted and parsed again instead of falling back to live-text-only, restoring karaoke reconstruction, sidebar cues, and mining for releases that only ship subtitles inside the container. Secondary subtitles now go through the same deduplication pipeline as primary subtitles and no longer clip display after about four lines. Event-heavy karaoke files that previously stalled subtitle loading for several seconds now parse in well under a second.
|
||||
- **Character Dictionary Reliability**: Character dictionary generation, merged rebuilds, and imports no longer freeze the app on large dictionaries. Snapshot I/O, archive building, and image/name lookup caches moved off the UI's critical path. Dictionaries are reused instead of regenerated when MeCab finds no name splits, and cached portraits now restore correctly after the portrait index finishes loading post-tokenization. Desktop progress notifications on Linux AppImage installs now update in place instead of flickering, fixing a bug where the AppImage's bundled libraries broke the system notification helper.
|
||||
- **Overlay Startup & Modals**: Fixed several causes of the overlay getting stuck on "Overlay loading": the macOS window-tracking helper now targets macOS 12.0+ instead of requiring the build machine's exact macOS version (previously crashed on older systems like Ventura), and mpv IPC connection attempts now time out and retry, showing an actionable error if content still isn't ready after 30 seconds. Dedicated overlay modals are also prewarmed on macOS and Windows so shortcuts open them promptly, and on macOS reused modals and the stats window now open above fullscreen mpv on its current Space instead of jumping to another desktop.
|
||||
- **Subtitle & Karaoke Duplication**:
|
||||
- Karaoke and animated signs are reconstructed once from their authored text and shown only while actually sung, with original word spacing preserved, instead of flooding the overlay, subtitle sidebar, immersion history, mining, or stats with glyph fragments, per-frame color phases, and repeated animation events.
|
||||
- Decorative layers (highlight sweeps, glow/shadow copies, symbol-font decoration, particle swarms, hidden or zero-scaled text) stay out of published text, while ordinary repeated dialogue, positioned signs, wrapped lyric rows, and multi-row CC-style blocks still display correctly.
|
||||
- Embedded subtitle tracks on network-mounted (SMB/NFS) media are extracted and parsed again instead of falling back to live-text-only, restoring karaoke reconstruction, sidebar cues, and mining for releases that only ship subtitles inside the container.
|
||||
- Secondary subtitles go through the same deduplication pipeline as primary subtitles and no longer clip display after about four lines.
|
||||
- Event-heavy karaoke files that previously stalled subtitle loading for several seconds now parse in well under a second.
|
||||
- **Character Dictionary Reliability**:
|
||||
- Generation, merged rebuilds, and imports no longer freeze the app on large dictionaries; snapshot I/O, archive building, and image/name lookup caches moved off the UI's critical path.
|
||||
- Dictionaries are reused instead of regenerated when MeCab finds no name splits.
|
||||
- Cached portraits restore correctly after the portrait index finishes loading post-tokenization.
|
||||
- Desktop progress notifications on Linux AppImage installs update in place instead of flickering, fixing a bug where the AppImage's bundled libraries broke the system notification helper.
|
||||
- **Overlay Startup & Modals**:
|
||||
- The macOS window-tracking helper targets macOS 12.0+ instead of requiring the build machine's exact macOS version, fixing crashes on older systems like Ventura that left the overlay stuck on "Overlay loading".
|
||||
- mpv IPC connection attempts time out and retry, showing an actionable error if content still isn't ready after 30 seconds.
|
||||
- Dedicated overlay modals are prewarmed on macOS and Windows so shortcuts open them promptly.
|
||||
- On macOS, reused modals and the stats window open above fullscreen mpv on its current Space instead of jumping to another desktop.
|
||||
- **Wayland File Drop**: Fixed native Wayland drag-and-drop from file managers such as Thunar, so subtitle and video files dropped on the visible overlay are resolved and forwarded to mpv.
|
||||
- **Windows Mouse Lag**: Fixed system-wide mouse lag on Windows while SubMiner is running, caused by the overlay's global mouse hook for click-through forwarding and by the mpv window tracker blocking the app on repeated PowerShell lookups.
|
||||
- **Sentence Mining Audio & Clips**: Sentence-audio generation no longer times out on slow network-mounted media with many subtitle/font streams (bounded FFmpeg probing, two-minute extraction budget, clearer error reporting), and mined audio/animated AVIF clips now capture the subtitle line that was actually mined by snapshotting the clip range at lookup time instead of reading live mpv state later.
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ How fragments turn into a release:
|
||||
|
||||
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
|
||||
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
|
||||
- GitHub release notes and prerelease notes use short top-level items with nested bullets for the change, user benefit, and any useful action note. The stable `CHANGELOG.md` can stay in compact single-line bullets.
|
||||
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use short top-level items with one nested bullet per distinct change, instead of packing a release's worth of detail into a single paragraph bullet. An item with only one thing to say stays inline on the top-level bullet. Release notes and prerelease notes additionally cover user benefit and any useful action note in their nested bullets.
|
||||
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
|
||||
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Dismissed active overlay card-update progress when notification settings switch to OSD before an update finishes.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: added
|
||||
area: mining
|
||||
|
||||
- Added optional pre-generation timing review for word, sentence, and audio cards with a compact speech-weighted waveform, clearly labeled mined-line boundaries, drag and keyboard adjustments, audio preview with a sweeping playhead, exact screenshot and AVIF timing, cancellation choices that include keeping a card without media, and a session-only runtime toggle.
|
||||
- The timing review can pull any number of previous and next subtitle lines onto the card: `P`/`N` (or the Prev/Next steppers) add lines one at a time, Shift removes them, the sentence preview highlights exactly what the card will contain, and the clip range and line boundary markers on the waveform follow the added lines automatically.
|
||||
@@ -523,7 +523,7 @@
|
||||
// ==========================================
|
||||
// AnkiConnect Integration
|
||||
// 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.
|
||||
// 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
|
||||
"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
|
||||
"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.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
|
||||
@@ -136,6 +136,8 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn
|
||||
|
||||
Field names are matched against your Anki note type case-insensitively (an exact match wins, then a lowercase comparison). If a configured field does not exist on the note type, SubMiner skips it without error.
|
||||
|
||||
These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names.
|
||||
|
||||
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
|
||||
|
||||
### Minimal Config
|
||||
@@ -166,6 +168,7 @@ Audio is extracted from the video file using the subtitle's start and end timest
|
||||
"generateAudio": true,
|
||||
"normalizeAudio": true, // normalize generated clip loudness
|
||||
"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
|
||||
"maxMediaDuration": 30 // cap total duration in seconds
|
||||
}
|
||||
@@ -178,6 +181,12 @@ 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]`.
|
||||
|
||||
Set `media.reviewTiming` to `true` to pause playback and review each word, sentence, or audio card before its media is generated. The review opens with the subtitle range plus configured audio padding. Drag either edge of the clip to trim it, drag the middle to slide it without changing its length, or press anywhere else on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys, by 100 ms alone or 500 ms with Shift, and the 100 ms buttons do the same. Space previews the selection with a playhead that sweeps the clip, Enter confirms, and Escape cancels. The Earlier and Later buttons reveal another two seconds of available timeline without moving the selected clip. A speech-weighted waveform shows the mined subtitle as a tinted band with labeled line-start and line-end rails, making adjacent dialogue easier to distinguish. SubMiner uses a center channel when one carries dialogue, then falls back to a speech-band mono mix. Waveform analysis failure leaves the timing controls available. The confirmed range is exact: SubMiner does not apply audio padding a second time. Static screenshots use its midpoint, and animated AVIF clips use the full confirmed range.
|
||||
|
||||
The review can also pull adjacent subtitle lines onto the card. Press `P` or `N` (or use the Prev and Next steppers above the sentence preview) to add the previous or next line, as many times as lines are available; Shift+`P` and Shift+`N` remove them again. The sentence preview lists every included line with the mined line highlighted, so the card's sentence field is always visible before you confirm, and the clip start or end, along with the line-start and line-end rails on the waveform, follows the outermost added line, keeping the review's audio padding. Confirming writes the combined lines to the sentence field; the Reset button drops the added lines along with any timing changes. Adjacent lines come from the parsed subtitle track when one is loaded; otherwise only lines that already played are offered, and a clip capped by `media.maxMediaDuration` keeps the full combined sentence even when the audio cannot cover every added line.
|
||||
|
||||
Canceling the review lets you keep editing, finish with the original timing, keep or create the card without audio or an image, or discard the card. Discard deletes an existing Yomitan or audio card and skips creation for a direct sentence card. Clipboard updates and stats-dashboard mining do not open timing review. Audio preview failure does not block confirmation or card creation. The option is disabled by default and hot-reloads. You can also toggle **Review Media Timing** for the current session from the runtime options palette (`Ctrl/Cmd+Shift+O`).
|
||||
|
||||
### Screenshots (Static)
|
||||
|
||||
A single frame is captured at the current playback position.
|
||||
@@ -233,7 +242,7 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
|
||||
|
||||
When media is available, mined-card overlay and system notifications include the same current-frame thumbnail.
|
||||
|
||||
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio, while leaving the word audio field unchanged.
|
||||
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio in `ankiConnect.fields.audio`, even when `overwriteAudio` is disabled.
|
||||
|
||||
## AI Translation
|
||||
|
||||
@@ -287,6 +296,8 @@ Sentence card creation and audio card marking require a non-empty `ankiConnect.i
|
||||
|
||||
Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is created directly via AnkiConnect with the sentence, audio, and image filled in.
|
||||
|
||||
The dedicated sentence-card and audio-card shortcuts use the Lapis/Kiku-compatible `Sentence` and `SentenceAudio` fields. This does not affect the configured fields used to enrich normal word cards.
|
||||
|
||||
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
|
||||
|
||||
## Word Card Type (Kiku/Lapis)
|
||||
|
||||
+31
-3
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.5 (2026-08-30)
|
||||
|
||||
**Fixed**
|
||||
|
||||
- **Anki Card Update Progress**: The card-update spinner now stays visible until audio and image updates finish, instead of disappearing early.
|
||||
- **Anki Word-Card Fields**: Word-card enrichment now writes sentence text and audio to the fields configured in AnkiConnect, while the dedicated sentence-card and audio-card actions keep their existing compatible field names.
|
||||
- **Overlapping Subtitles**:
|
||||
- Subtitle lines that start while another line is still on screen now appear alongside it, instead of staying hidden until a track switch or seek.
|
||||
- Subtitles shown at the same time now stack by their authored screen position, with top signs and song lines above bottom dialogue.
|
||||
- Half-size ASS furigana is no longer shown as if it were a dialogue line.
|
||||
- **YouTube Auto Captions**:
|
||||
- Auto-generated captions now follow their intended timing and two-row roll-up layout.
|
||||
- Long speech is paged instead of covering the video with a wall of text.
|
||||
- Explicitly timed sound cues like `[音楽]` no longer cover later dialogue.
|
||||
|
||||
## v0.19.4 (2026-08-25)
|
||||
|
||||
**Added**
|
||||
@@ -10,9 +25,22 @@
|
||||
- **Prerelease Release Notes**: Prerelease notes now open with a "Changes since" section listing only what changed versus the previous beta/RC of the same version, above the cumulative highlights, and CI rejects prerelease tags whose committed notes were generated for a different beta/RC.
|
||||
|
||||
**Fixed**
|
||||
- **Subtitle & Karaoke Duplication**: Typeset ASS karaoke and animated signs no longer flood the overlay, subtitle sidebar, immersion history, mining, or stats with glyph fragments, per-frame color phases, or repeated animation events. Lines are reconstructed once from their authored text and shown only while actually sung, with original word spacing preserved. Decorative layers (highlight sweeps, glow/shadow copies, symbol-font decoration, particle swarms, hidden or zero-scaled text) stay out of published text, while ordinary repeated dialogue, positioned signs, wrapped lyric rows, and multi-row CC-style blocks still display correctly. Embedded subtitle tracks on network-mounted (SMB/NFS) media are extracted and parsed again instead of falling back to live-text-only, restoring karaoke reconstruction, sidebar cues, and mining for releases that only ship subtitles inside the container. Secondary subtitles now go through the same deduplication pipeline as primary subtitles and no longer clip display after about four lines. Event-heavy karaoke files that previously stalled subtitle loading for several seconds now parse in well under a second.
|
||||
- **Character Dictionary Reliability**: Character dictionary generation, merged rebuilds, and imports no longer freeze the app on large dictionaries. Snapshot I/O, archive building, and image/name lookup caches moved off the UI's critical path. Dictionaries are reused instead of regenerated when MeCab finds no name splits, and cached portraits now restore correctly after the portrait index finishes loading post-tokenization. Desktop progress notifications on Linux AppImage installs now update in place instead of flickering, fixing a bug where the AppImage's bundled libraries broke the system notification helper.
|
||||
- **Overlay Startup & Modals**: Fixed several causes of the overlay getting stuck on "Overlay loading": the macOS window-tracking helper now targets macOS 12.0+ instead of requiring the build machine's exact macOS version (previously crashed on older systems like Ventura), and mpv IPC connection attempts now time out and retry, showing an actionable error if content still isn't ready after 30 seconds. Dedicated overlay modals are also prewarmed on macOS and Windows so shortcuts open them promptly, and on macOS reused modals and the stats window now open above fullscreen mpv on its current Space instead of jumping to another desktop.
|
||||
- **Subtitle & Karaoke Duplication**:
|
||||
- Karaoke and animated signs are reconstructed once from their authored text and shown only while actually sung, with original word spacing preserved, instead of flooding the overlay, subtitle sidebar, immersion history, mining, or stats with glyph fragments, per-frame color phases, and repeated animation events.
|
||||
- Decorative layers (highlight sweeps, glow/shadow copies, symbol-font decoration, particle swarms, hidden or zero-scaled text) stay out of published text, while ordinary repeated dialogue, positioned signs, wrapped lyric rows, and multi-row CC-style blocks still display correctly.
|
||||
- Embedded subtitle tracks on network-mounted (SMB/NFS) media are extracted and parsed again instead of falling back to live-text-only, restoring karaoke reconstruction, sidebar cues, and mining for releases that only ship subtitles inside the container.
|
||||
- Secondary subtitles go through the same deduplication pipeline as primary subtitles and no longer clip display after about four lines.
|
||||
- Event-heavy karaoke files that previously stalled subtitle loading for several seconds now parse in well under a second.
|
||||
- **Character Dictionary Reliability**:
|
||||
- Generation, merged rebuilds, and imports no longer freeze the app on large dictionaries; snapshot I/O, archive building, and image/name lookup caches moved off the UI's critical path.
|
||||
- Dictionaries are reused instead of regenerated when MeCab finds no name splits.
|
||||
- Cached portraits restore correctly after the portrait index finishes loading post-tokenization.
|
||||
- Desktop progress notifications on Linux AppImage installs update in place instead of flickering, fixing a bug where the AppImage's bundled libraries broke the system notification helper.
|
||||
- **Overlay Startup & Modals**:
|
||||
- The macOS window-tracking helper targets macOS 12.0+ instead of requiring the build machine's exact macOS version, fixing crashes on older systems like Ventura that left the overlay stuck on "Overlay loading".
|
||||
- mpv IPC connection attempts time out and retry, showing an actionable error if content still isn't ready after 30 seconds.
|
||||
- Dedicated overlay modals are prewarmed on macOS and Windows so shortcuts open them promptly.
|
||||
- On macOS, reused modals and the stats window open above fullscreen mpv on its current Space instead of jumping to another desktop.
|
||||
- **Wayland File Drop**: Fixed native Wayland drag-and-drop from file managers such as Thunar, so subtitle and video files dropped on the visible overlay are resolved and forwarded to mpv.
|
||||
- **Windows Mouse Lag**: Fixed system-wide mouse lag on Windows while SubMiner is running, caused by the overlay's global mouse hook for click-through forwarding and by the mpv window tracker blocking the app on repeated PowerShell lookups.
|
||||
- **Sentence Mining Audio & Clips**: Sentence-audio generation no longer times out on slow network-mounted media with many subtitle/font streams (bounded FFmpeg probing, two-minute extraction budget, clearer error reporting), and mined audio/animated AVIF clips now capture the subtitle line that was actually mined by snapshotting the clip range at lookup time instead of reading live mpv state later.
|
||||
|
||||
@@ -873,9 +873,10 @@ When config hot-reload updates shortcut/keybinding/style values, close and reope
|
||||
|
||||
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
|
||||
|
||||
Current runtime options cover automatic card updates, known-word highlighting,
|
||||
known-word maturity coloring, N+1 annotation, JLPT underlines, frequency
|
||||
highlighting, known-word match mode, and Kiku field grouping mode.
|
||||
Current runtime options cover automatic card updates, media timing review,
|
||||
known-word highlighting, known-word maturity coloring, N+1 annotation, JLPT
|
||||
underlines, frequency highlighting, known-word match mode, and Kiku field
|
||||
grouping mode.
|
||||
|
||||
Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
|
||||
|
||||
@@ -967,6 +968,7 @@ Enable automatic Anki card creation and updates with media generation:
|
||||
"animatedCrf": 35,
|
||||
"normalizeAudio": true,
|
||||
"mirrorMpvVolume": true,
|
||||
"reviewTiming": false,
|
||||
"audioPadding": 0,
|
||||
"fallbackDuration": 3,
|
||||
"maxMediaDuration": 30
|
||||
@@ -1019,6 +1021,7 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `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.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.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||
|
||||
@@ -41,7 +41,7 @@ If you prefer a hands-on approach (animecards-style), you can copy the current s
|
||||
- For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1`–`9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard.
|
||||
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation - the same fields auto-update would fill.
|
||||
|
||||
Manual clipboard updates always replace generated sentence audio, even when `ankiConnect.behavior.overwriteAudio` is disabled. The word audio field is left unchanged because the word itself does not change in this flow.
|
||||
Manual clipboard updates always replace generated sentence audio in `ankiConnect.fields.audio`, even when `ankiConnect.behavior.overwriteAudio` is disabled. Normal word-card updates use the configured sentence and audio fields even when Lapis or Kiku support is enabled.
|
||||
|
||||
This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card.
|
||||
|
||||
|
||||
@@ -523,7 +523,7 @@
|
||||
// ==========================================
|
||||
// AnkiConnect Integration
|
||||
// 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.
|
||||
// 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
|
||||
"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
|
||||
"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.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
|
||||
@@ -129,6 +129,9 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
||||
between ordinary, hard, or ideographic spaces appear once.
|
||||
- Simultaneous ASS lines are flattened in top-to-bottom positioned order, falling back to their
|
||||
authored source order when no usable position exists.
|
||||
- Half-size kana positioned directly above a same-timed kanji caption is treated as ASS
|
||||
furigana. The parser omits it from published cues but retains hidden matching metadata so
|
||||
mpv's raw live text can be reconciled without displaying or mining the reading.
|
||||
- Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces
|
||||
survive concatenation. Latin fragment typesetting with no literal spaces also recovers word
|
||||
boundaries represented only by materially larger horizontal `\pos` or `\move` gaps within that
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.19.4",
|
||||
"version": "0.19.5",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
## Highlights
|
||||
### Added
|
||||
- **Library Duplicate & Misfiled Episode Tools**
|
||||
- Merge duplicate show cards from the Library grid: select cards and use "Merge Selected" to combine sessions, mined cards, and watch time onto one entry while keeping remembered title aliases.
|
||||
- Reassign a misfiled episode to the correct show with the "→" button on an episode row; the fix survives later filename parsing, Jellyfin refreshes, and season repair.
|
||||
- Exact AniList matches merge automatically, while likely (fuzzy) matches surface as a dismissible "Possible duplicate" suggestion instead of merging without confirmation.
|
||||
- **Stats Duplicate-Line Cleanup Tool**
|
||||
- The Vocabulary tab's new Duplicates button scans a chosen time window for old karaoke/animation duplicate bursts and collapses each one to a single line after you confirm, without touching watch time or lines-seen totals.
|
||||
- The same cleanup is available from the terminal via `subminer stats cleanup --duplicate-lines`, with `--dry-run` and `--lookback-days` options.
|
||||
|
||||
### Changed
|
||||
- **Prerelease Notes "Changes Since" Section**
|
||||
- Prerelease release notes now open with a "Changes since" section listing only what changed since the previous beta/RC of the same version, shown above the full cumulative highlights.
|
||||
|
||||
### Fixed
|
||||
- **Subtitle Deduplication & Karaoke Reconstruction**
|
||||
- Typeset ASS karaoke and animated signs are reconstructed into their authored line and shown once, instead of flooding the overlay, subtitle sidebar, immersion history, sentence mining, and stats with per-frame glyph fragments and repeated lyric bursts (a lyric could previously pin itself to the top of "Top Repeated Words").
|
||||
- The same deduplication now applies consistently everywhere, including embedded subtitles extracted from network-mounted (SMB/NFS) media and the secondary subtitle overlay, while ordinary repeated dialogue, signs, and rewatches remain unaffected.
|
||||
- Secondary subtitle overlays no longer clip long lines after about four rows, and no longer show scattered-letter or duplicated text while embedded subtitles are still being extracted.
|
||||
- **Character Dictionary Reliability & Notifications**
|
||||
- Character dictionary generation, rebuilds, and imports no longer freeze the app or trigger "not responding" dialogs on large dictionaries; the heavy work now runs off the main UI thread.
|
||||
- Dictionaries are reused instead of being regenerated on every launch when no name splits were found, and portraits reappear correctly once the cached portrait index finishes loading.
|
||||
- Linux desktop progress notifications, including on AppImage installs, now update in place instead of flickering closed and reopening.
|
||||
- **Overlay Startup Reliability**
|
||||
- The overlay no longer gets stuck on an endless "Overlay loading" screen when mpv's connection stalls at startup; connections now time out and retry, and a clear error appears if content still isn't ready after 30 seconds.
|
||||
- **Overlay Modal Windows (macOS & Windows)**
|
||||
- Modal windows such as Settings prewarm so shortcuts open them promptly on first press.
|
||||
- On Windows, the hidden modal renderer now refreshes between sessions so later modals stay interactive.
|
||||
- On macOS, reused modals and the stats window open above fullscreen mpv on the correct Space instead of jumping to another desktop; the overlay-attach helper also now supports macOS 12.0+, fixing "Overlay loading" getting stuck on older macOS versions.
|
||||
- **Windows Mouse Lag**
|
||||
- Fixed system-wide mouse lag while SubMiner is running: the overlay no longer installs a global mouse hook, and the mpv window tracker no longer blocks the app with repeated command-line lookups.
|
||||
- **Linux Overlay & Launcher Fixes**
|
||||
- Native Wayland drag-and-drop from file managers such as Thunar now works, so subtitle and video files dropped on the overlay reach mpv.
|
||||
- Fixed missing MKV thumbnails in the rofi file picker on systems that only advertise legacy Matroska MIME aliases.
|
||||
- **Sentence Mining Audio & Clip Accuracy**
|
||||
- Sentence-audio generation no longer times out on slow network-mounted MKV files with many subtitle/font streams; probing is now bounded with a two-minute extraction budget and a clear error instead of a raw failure.
|
||||
- Mined audio and animated clips now capture the exact subtitle line that was mined, instead of whatever line was on screen after audio extraction finished, fixing too-short or misaligned clips.
|
||||
- **Stats Reliability & Performance**
|
||||
- Fixed transient database-lock errors when multiple stats workers wrote at once.
|
||||
- Stats deletes, library merges, video moves, and AniList reassignments no longer freeze the dashboard or rebuild lifetime totals from scratch, so they're fast and preserve lifetime totals older than the recent session-retention window; session deletes on large databases dropped from minutes to milliseconds.
|
||||
- **Vocabulary Tab Accuracy**
|
||||
- Vocabulary totals and charts now count all tracked vocabulary instead of only the first page, with new-word history rebuilt from corrected daily rollups to match.
|
||||
- Calendar charts keep the correct local date in time zones west of UTC, and vocabulary cards/charts now refresh automatically and retry after the word exclusion list changes.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(stats): add library entry merge and episode move by @ksyasuda in #190
|
||||
- fix(stats): stop counting duplicate typeset subtitle lines by @ksyasuda in #191
|
||||
- fix(media): tolerate slow MKV audio extraction by @ksyasuda in #195
|
||||
- fix(stats): subtract lifetime totals incrementally on delete by @ksyasuda in #196
|
||||
- fix(anki): snapshot mining media clip timing by @ksyasuda in #197
|
||||
- fix(notifications): replace Linux progress updates in place by @ksyasuda in #198
|
||||
- fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199
|
||||
- fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200
|
||||
- fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201
|
||||
- fix(stats): report complete vocabulary totals and new-word history by @ksyasuda in #202
|
||||
- fix(mpv): recover from stalled IPC connects by @ksyasuda in #204
|
||||
- fix(dictionary): prevent freezes and restore AppImage notifications by @ksyasuda in #205
|
||||
- fix(subtitles): recover canonical lines from ASS animation by @ksyasuda in #207
|
||||
- fix(overlay): deduplicate secondary subtitle rendering by @ksyasuda in #208
|
||||
- fix(launcher): restore Matroska thumbnails in Linux rofi picker by @ksyasuda in #210
|
||||
- fix(character-dictionary): cache completed MeCab refreshes by @ksyasuda in #212
|
||||
- fix(subtitles): improve secondary subtitle extraction and display by @ksyasuda in #215
|
||||
- feat(release): track prerelease deltas and validate committed notes by @ksyasuda in #216
|
||||
- fix(subtitles): recover positioned ASS word spacing and drop control debris by @ksyasuda in #217
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
@@ -44,14 +44,22 @@ function fragmentTypesInPrompt(input: string): string[] {
|
||||
.map((line) => line.slice('type: '.length).trim());
|
||||
}
|
||||
|
||||
function assertReleaseNotesPromptRequestsNestedBullets(input: string): void {
|
||||
assert.match(input, /In MODE: release-notes, use short top-level change bullets/);
|
||||
assert.match(input, /Nested bullets should cover the change, user benefit, and any user action/);
|
||||
assert.match(input, /Do not require the exact nested labels/);
|
||||
function assertPromptRequestsNestedBullets(input: string): void {
|
||||
assert.match(input, /In both modes, split every item into one nested bullet per distinct change/);
|
||||
assert.match(input, /Never stack several distinct changes into one long paragraph-shaped bullet/);
|
||||
assert.match(input, /Keep nested bullets short, concrete, and readable by non-technical users/);
|
||||
assert.match(input, /Avoid paragraph-style release-note bullets/);
|
||||
}
|
||||
|
||||
function assertReleaseNotesPromptRequestsNestedBullets(input: string): void {
|
||||
assertPromptRequestsNestedBullets(input);
|
||||
assert.match(
|
||||
input,
|
||||
/In MODE: release-notes, nested bullets should also cover user benefit and any user action/,
|
||||
);
|
||||
assert.match(input, /Do not require the exact nested labels/);
|
||||
}
|
||||
|
||||
function defaultPolishedBody(input: string): string {
|
||||
const mode = modeFromPrompt(input);
|
||||
const types = fragmentTypesInPrompt(input);
|
||||
@@ -446,6 +454,7 @@ test('writeChangelogArtifacts prompts Claude to summarize the final stable outco
|
||||
prompt,
|
||||
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
|
||||
);
|
||||
assertPromptRequestsNestedBullets(prompt);
|
||||
}
|
||||
|
||||
const releaseNotesPrompt = stub.calls.find(
|
||||
|
||||
@@ -480,10 +480,15 @@ You will receive a list of FRAGMENT entries below. Each fragment has metadata (t
|
||||
- Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five.
|
||||
- Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users.
|
||||
- Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules. Do not soften or omit them.
|
||||
5. In MODE: changelog, each item may be a conventional single-level bullet, e.g. "- Playlist Browser: Adds faster saved-show browsing."
|
||||
6. In MODE: release-notes, use short top-level change bullets with two or three nested bullets when an item needs explanation.
|
||||
Nested bullets should cover the change, user benefit, and any user action or compatibility note when useful. Do not require the exact nested labels; natural phrasing is fine. Omit the action bullet when no action is needed.
|
||||
5. In both modes, split every item into one nested bullet per distinct change. Write a short bold name on the top-level bullet, then indent the details two spaces:
|
||||
- **Playlist Browser**:
|
||||
- Saved shows now open without rescanning the library.
|
||||
- The picker remembers the last folder you browsed between launches.
|
||||
Each nested bullet covers exactly one change, behavior, or user-visible outcome. Never stack several distinct changes into one long paragraph-shaped bullet.
|
||||
Aim for two to five nested bullets per item. When an item genuinely has only one thing to say, put it inline on the top-level bullet ("- **Playlist Browser**: Saved shows now open without rescanning the library.") instead of emitting a single nested bullet.
|
||||
Keep nested bullets short, concrete, and readable by non-technical users. Avoid paragraph-style release-note bullets.
|
||||
Bullets inside the Internal section may stay single-level.
|
||||
6. In MODE: release-notes, nested bullets should also cover user benefit and any user action or compatibility note when useful. Do not require the exact nested labels; natural phrasing is fine. Omit the action bullet when no action is needed.
|
||||
7. Do not invent features. Every bullet must be grounded in the input fragments.
|
||||
8. Do not include the version heading (## v...) — that wrapper is added by the caller.
|
||||
|
||||
|
||||
@@ -11,10 +11,12 @@ import type { MediaInput } from './media-input';
|
||||
import { AnkiConnectConfig } from './types';
|
||||
|
||||
type TestOverlayNotificationPayload = {
|
||||
id?: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
image?: string;
|
||||
variant?: string;
|
||||
persistent?: boolean;
|
||||
actions?: Array<{ id: string; label: string; noteId?: number }>;
|
||||
};
|
||||
|
||||
@@ -606,6 +608,7 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
fields: {
|
||||
audio: 'ExpressionAudio',
|
||||
image: 'Picture',
|
||||
},
|
||||
media: {
|
||||
@@ -659,7 +662,7 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
|
||||
noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
ExpressionAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
})),
|
||||
@@ -944,7 +947,7 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
||||
noteInfo: {
|
||||
noteId: 404,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
ExpressionAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
},
|
||||
@@ -956,7 +959,8 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
||||
assert.equal(queued, true);
|
||||
assert.equal(updatedNotes.length, 1);
|
||||
assert.equal(updatedNotes[0]?.noteId, 404);
|
||||
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/);
|
||||
assert.match(updatedNotes[0]?.fields.ExpressionAudio ?? '', /^\[sound:audio_/);
|
||||
assert.equal(updatedNotes[0]?.fields.SentenceAudio, undefined);
|
||||
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
|
||||
assert.equal(storedMedia.length, 2);
|
||||
assert.deepEqual(audioVolumeScales, [0.3 ** 3]);
|
||||
@@ -1182,6 +1186,117 @@ test('AnkiIntegration embeds generated notification image on overlay mined-card
|
||||
assert.deepEqual(cleanupPaths, [notificationIconPath]);
|
||||
});
|
||||
|
||||
test('AnkiIntegration keeps overlay card-update progress visible until the terminal notification', async () => {
|
||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
behavior: {
|
||||
notificationType: 'overlay',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{} as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
(payload) => {
|
||||
overlayNotifications.push(payload);
|
||||
},
|
||||
);
|
||||
const updateNotifications = integration as unknown as {
|
||||
beginUpdateProgress: (message: string) => void;
|
||||
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||
};
|
||||
|
||||
updateNotifications.beginUpdateProgress('Updating card');
|
||||
await updateNotifications.showNotification(42, '食べる');
|
||||
|
||||
assert.deepEqual(
|
||||
overlayNotifications.map(({ id, variant, persistent }) => ({ id, variant, persistent })),
|
||||
[
|
||||
{ id: 'anki-update-progress', variant: 'progress', persistent: true },
|
||||
{ id: 'anki-update-progress', variant: 'success', persistent: false },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('AnkiIntegration dismisses persistent overlay update progress when no terminal notification replaces it', () => {
|
||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||
const dismissedIds: string[] = [];
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
behavior: {
|
||||
notificationType: 'overlay',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{} as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
(payload) => {
|
||||
overlayNotifications.push(payload);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
(id) => {
|
||||
dismissedIds.push(id);
|
||||
},
|
||||
);
|
||||
const updateNotifications = integration as unknown as {
|
||||
beginUpdateProgress: (message: string) => void;
|
||||
endUpdateProgress: () => void;
|
||||
};
|
||||
|
||||
updateNotifications.beginUpdateProgress('Updating card');
|
||||
updateNotifications.endUpdateProgress();
|
||||
|
||||
assert.equal(overlayNotifications[0]?.persistent, true);
|
||||
assert.deepEqual(dismissedIds, ['anki-update-progress']);
|
||||
});
|
||||
|
||||
test('AnkiIntegration dismisses overlay update progress after notifications switch to OSD', () => {
|
||||
const behavior: NonNullable<AnkiConnectConfig['behavior']> = {
|
||||
notificationType: 'overlay',
|
||||
};
|
||||
const dismissedIds: string[] = [];
|
||||
const integration = new AnkiIntegration(
|
||||
{ behavior },
|
||||
{} as never,
|
||||
{} as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
() => {},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
(id) => {
|
||||
dismissedIds.push(id);
|
||||
},
|
||||
);
|
||||
const updateNotifications = integration as unknown as {
|
||||
beginUpdateProgress: (message: string) => void;
|
||||
endUpdateProgress: () => void;
|
||||
};
|
||||
|
||||
updateNotifications.beginUpdateProgress('Updating card');
|
||||
behavior.notificationType = 'osd';
|
||||
updateNotifications.endUpdateProgress();
|
||||
|
||||
assert.deepEqual(dismissedIds, ['anki-update-progress']);
|
||||
});
|
||||
|
||||
test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => {
|
||||
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
|
||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||
|
||||
+52
-15
@@ -28,6 +28,8 @@ import {
|
||||
KikuMergePreviewResponse,
|
||||
NotificationOptions,
|
||||
type WordCardKind,
|
||||
type MediaTimingReviewDecision,
|
||||
type MediaTimingReviewRequest,
|
||||
} from './types/anki';
|
||||
import { AiConfig } from './types/integrations';
|
||||
import type { KnownWordMaturityTier } from './types/subtitle';
|
||||
@@ -218,6 +220,8 @@ export class AnkiIntegration {
|
||||
null;
|
||||
private overlayNotificationCallback: ((payload: OverlayNotificationPayload) => void) | null =
|
||||
null;
|
||||
private overlayNotificationDismissCallback: ((id: string) => void) | null = null;
|
||||
private overlayUpdateProgressActive = false;
|
||||
private updateInProgress = false;
|
||||
private uiFeedbackState: UiFeedbackState = createUiFeedbackState();
|
||||
private parseWarningKeys = new Set<string>();
|
||||
@@ -238,6 +242,9 @@ export class AnkiIntegration {
|
||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
||||
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
||||
private mediaTimingReviewCallback:
|
||||
| ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>)
|
||||
| null = null;
|
||||
private noteIdRedirects = new Map<number, number>();
|
||||
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
||||
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
|
||||
@@ -265,6 +272,7 @@ export class AnkiIntegration {
|
||||
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'],
|
||||
shouldRequireRemoteMediaCache?: () => boolean,
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined,
|
||||
overlayNotificationDismissCallback?: (id: string) => void,
|
||||
) {
|
||||
this.config = normalizeAnkiIntegrationConfig(config);
|
||||
this.aiConfig = { ...aiConfig };
|
||||
@@ -280,6 +288,7 @@ export class AnkiIntegration {
|
||||
this.getCachedMediaPath = getCachedMediaPath ?? null;
|
||||
this.shouldRequireRemoteMediaCache = shouldRequireRemoteMediaCache ?? null;
|
||||
this.getYoutubeMediaSourceUrl = getYoutubeMediaSourceUrl ?? null;
|
||||
this.overlayNotificationDismissCallback = overlayNotificationDismissCallback ?? null;
|
||||
this.pendingYoutubeMediaQueue = this.createPendingYoutubeMediaQueue();
|
||||
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
|
||||
this.pollingRunner = this.createPollingRunner();
|
||||
@@ -379,8 +388,6 @@ export class AnkiIntegration {
|
||||
getCachedMediaPath: this.getCachedMediaPath,
|
||||
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
|
||||
getSubtitleMediaRange: (context) => this.getSubtitleMediaRange(context),
|
||||
getResolvedSentenceAudioFieldName: (noteInfo) =>
|
||||
this.getResolvedSentenceAudioFieldName(noteInfo),
|
||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
||||
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
|
||||
mergeFieldValue: (existing, newValue, overwrite) =>
|
||||
@@ -509,6 +516,7 @@ export class AnkiIntegration {
|
||||
findNotes: async (query, options) =>
|
||||
(await this.client.findNotes(query, options)) as number[],
|
||||
retrieveMediaFile: (filename) => this.client.retrieveMediaFile(filename),
|
||||
deleteNotes: (noteIds) => this.client.deleteNotes(noteIds),
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: (
|
||||
@@ -566,6 +574,7 @@ export class AnkiIntegration {
|
||||
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
||||
getFallbackDurationSeconds: () => this.getFallbackDurationSeconds(),
|
||||
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
|
||||
removeKnownWordNote: (noteId) => this.removeKnownWordNote(noteId),
|
||||
isUpdateInProgress: () => this.updateInProgress,
|
||||
setUpdateInProgress: (value) => {
|
||||
this.updateInProgress = value;
|
||||
@@ -581,6 +590,7 @@ export class AnkiIntegration {
|
||||
recordCardsMinedCallback: (count, noteIds) => {
|
||||
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
||||
},
|
||||
reviewMediaTiming: (request) => this.reviewMediaTiming(request),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -637,12 +647,14 @@ export class AnkiIntegration {
|
||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
||||
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
|
||||
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
|
||||
deleteNotes: (noteIds) => this.client.deleteNotes(noteIds),
|
||||
},
|
||||
getConfig: () => this.config,
|
||||
getCurrentSubtitleText: () => this.mpvClient.currentSubText,
|
||||
getCurrentSubtitleStart: () => this.mpvClient.currentSubStart,
|
||||
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
||||
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
|
||||
removeKnownWordNote: (noteId) => this.removeKnownWordNote(noteId),
|
||||
extractFields: (fields) => this.extractFields(fields),
|
||||
findDuplicateNote: (expression, excludeNoteId, noteInfo) =>
|
||||
this.findDuplicateNote(expression, excludeNoteId, noteInfo),
|
||||
@@ -657,8 +669,6 @@ export class AnkiIntegration {
|
||||
this.setCardTypeFields(updatedFields, availableFieldNames, cardKind),
|
||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
||||
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
|
||||
getResolvedSentenceAudioFieldName: (noteInfo) =>
|
||||
this.getResolvedSentenceAudioFieldName(noteInfo),
|
||||
getAnimatedImageLeadInSeconds: (noteInfo) => this.getAnimatedImageLeadInSeconds(noteInfo),
|
||||
mergeFieldValue: (existing, newValue, overwrite) =>
|
||||
this.mergeFieldValue(existing, newValue, overwrite),
|
||||
@@ -680,6 +690,7 @@ export class AnkiIntegration {
|
||||
logWarn: (...args) => log.warn(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)),
|
||||
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 {
|
||||
if (!this.knownWordCacheUpdatedCallback) {
|
||||
return;
|
||||
@@ -1039,7 +1056,7 @@ export class AnkiIntegration {
|
||||
videoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
this.config.media?.audioPadding,
|
||||
context?.mediaPaddingSeconds ?? this.config.media?.audioPadding,
|
||||
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
||||
this.config.media?.normalizeAudio !== false,
|
||||
await this.getMpvVolumeScale(),
|
||||
@@ -1072,7 +1089,7 @@ export class AnkiIntegration {
|
||||
videoPath,
|
||||
mediaRange.startTime,
|
||||
mediaRange.endTime,
|
||||
this.config.media?.audioPadding,
|
||||
context?.mediaPaddingSeconds ?? this.config.media?.audioPadding,
|
||||
{
|
||||
fps: this.config.media?.animatedFps,
|
||||
maxWidth: this.config.media?.animatedMaxWidth,
|
||||
@@ -1203,12 +1220,13 @@ export class AnkiIntegration {
|
||||
private beginUpdateProgress(initialMessage: string): void {
|
||||
if (!this.shouldUseOsdNotifications()) {
|
||||
if (this.shouldUseOverlayNotifications()) {
|
||||
this.overlayUpdateProgressActive = true;
|
||||
this.overlayNotificationCallback?.({
|
||||
id: 'anki-update-progress',
|
||||
title: 'Anki update',
|
||||
body: initialMessage,
|
||||
variant: 'progress',
|
||||
persistent: false,
|
||||
persistent: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -1219,6 +1237,10 @@ export class AnkiIntegration {
|
||||
}
|
||||
|
||||
private endUpdateProgress(): void {
|
||||
if (this.overlayUpdateProgressActive) {
|
||||
this.overlayUpdateProgressActive = false;
|
||||
this.overlayNotificationDismissCallback?.('anki-update-progress');
|
||||
}
|
||||
if (!this.shouldUseOsdNotifications()) {
|
||||
return;
|
||||
}
|
||||
@@ -1243,18 +1265,20 @@ export class AnkiIntegration {
|
||||
if (!this.shouldUseOsdNotifications()) {
|
||||
this.updateInProgress = true;
|
||||
if (this.shouldUseOverlayNotifications()) {
|
||||
this.overlayUpdateProgressActive = true;
|
||||
this.overlayNotificationCallback?.({
|
||||
id: 'anki-update-progress',
|
||||
title: 'Anki update',
|
||||
body: initialMessage,
|
||||
variant: 'progress',
|
||||
persistent: false,
|
||||
persistent: true,
|
||||
});
|
||||
}
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
this.updateInProgress = false;
|
||||
this.endUpdateProgress();
|
||||
}
|
||||
}
|
||||
return withUpdateProgress(
|
||||
@@ -1353,6 +1377,7 @@ export class AnkiIntegration {
|
||||
: undefined;
|
||||
|
||||
if (shouldShowOverlayNotification && this.overlayNotificationCallback) {
|
||||
this.overlayUpdateProgressActive = false;
|
||||
this.overlayNotificationCallback({
|
||||
id: 'anki-update-progress',
|
||||
title: 'Anki Card Updated',
|
||||
@@ -1555,13 +1580,6 @@ export class AnkiIntegration {
|
||||
return sentenceCardConfig.audioField || 'SentenceAudio';
|
||||
}
|
||||
|
||||
private getResolvedSentenceAudioFieldName(noteInfo: NoteInfo): string | null {
|
||||
return (
|
||||
this.resolveNoteFieldName(noteInfo, this.getPreferredSentenceAudioFieldName()) ||
|
||||
this.resolveConfiguredFieldName(noteInfo, this.config.fields?.audio)
|
||||
);
|
||||
}
|
||||
|
||||
private getConfiguredWordFieldName(): string {
|
||||
return getConfiguredWordFieldName(this.config);
|
||||
}
|
||||
@@ -1723,6 +1741,25 @@ export class AnkiIntegration {
|
||||
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 {
|
||||
let resolved = noteId;
|
||||
const seen = new Set<number>();
|
||||
|
||||
@@ -85,6 +85,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||
},
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => Buffer.from('audio'),
|
||||
@@ -129,6 +130,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
@@ -143,7 +145,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||
};
|
||||
}
|
||||
|
||||
test('manual clipboard subtitle update replaces sentence audio without touching expression audio', async () => {
|
||||
test('manual clipboard subtitle update replaces audio in the configured field', async () => {
|
||||
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService();
|
||||
|
||||
await service.updateLastAddedFromClipboard('字幕');
|
||||
@@ -151,14 +153,146 @@ test('manual clipboard subtitle update replaces sentence audio without touching
|
||||
assert.equal(updatedFields.length, 1);
|
||||
assert.equal(storedMedia.length, 1);
|
||||
const audioValue = `[sound:${storedMedia[0]}]`;
|
||||
assert.equal(updatedFields[0]?.SentenceAudio, audioValue);
|
||||
assert.equal('ExpressionAudio' in updatedFields[0]!, false);
|
||||
assert.equal(updatedFields[0]?.ExpressionAudio, audioValue);
|
||||
assert.equal('SentenceAudio' in updatedFields[0]!, false);
|
||||
assert.deepEqual(
|
||||
mergeCalls.map((call) => call.overwrite),
|
||||
[true],
|
||||
);
|
||||
});
|
||||
|
||||
test('manual clipboard word-card update uses configured fields with Lapis and Kiku enabled', async () => {
|
||||
const { service, updatedFields } = createManualUpdateService({
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Context',
|
||||
audio: 'ContextAudio',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: false,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
behavior: {
|
||||
overwriteAudio: false,
|
||||
overwriteImage: false,
|
||||
},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
client: {
|
||||
addNote: async () => 0,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: '単語' },
|
||||
Sentence: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
Context: { value: '' },
|
||||
ContextAudio: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async (_noteId, fields) => {
|
||||
updatedFields.push(fields);
|
||||
},
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
}),
|
||||
});
|
||||
|
||||
await service.updateLastAddedFromClipboard('字幕');
|
||||
|
||||
assert.equal(updatedFields.length, 1);
|
||||
assert.match(updatedFields[0]?.ContextAudio ?? '', /^\[sound:audio_\d+\.mp3\]$/);
|
||||
assert.deepEqual(Object.keys(updatedFields[0] ?? {}).sort(), ['Context', 'ContextAudio']);
|
||||
assert.equal(updatedFields[0]?.Context, '字幕');
|
||||
});
|
||||
|
||||
test('audio-card action keeps Lapis and Kiku sentence fields', async () => {
|
||||
const { service, updatedFields } = createManualUpdateService({
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Context',
|
||||
audio: 'ContextAudio',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: false,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
behavior: {},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: '/video.mp4',
|
||||
currentAudioStreamIndex: 0,
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 12,
|
||||
currentSubEnd: 14,
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async () => 0,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: '単語' },
|
||||
Sentence: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
Context: { value: '' },
|
||||
ContextAudio: { value: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async (_noteId, fields) => {
|
||||
updatedFields.push(fields);
|
||||
},
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
}),
|
||||
});
|
||||
|
||||
await service.markLastCardAsAudioCard();
|
||||
|
||||
assert.equal(updatedFields.length, 1);
|
||||
assert.equal(updatedFields[0]?.Sentence, '字幕');
|
||||
assert.match(updatedFields[0]?.SentenceAudio ?? '', /^\[sound:audio_\d+\.mp3\]$/);
|
||||
assert.equal('Context' in (updatedFields[0] ?? {}), false);
|
||||
assert.equal('ContextAudio' in (updatedFields[0] ?? {}), false);
|
||||
});
|
||||
|
||||
test('manual clipboard subtitle update marks Kiku word cards as word-and-sentence cards when enabled', async () => {
|
||||
const { service, updatedFields } = createManualUpdateService({
|
||||
getConfig: () =>
|
||||
@@ -201,6 +335,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
@@ -225,7 +360,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
|
||||
});
|
||||
});
|
||||
|
||||
test('manual clipboard subtitle update skips audio when sentence audio field is missing', async () => {
|
||||
test('manual clipboard subtitle update uses configured audio when SentenceAudio is missing', async () => {
|
||||
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({
|
||||
client: {
|
||||
addNote: async () => 0,
|
||||
@@ -248,6 +383,7 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
|
||||
},
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -255,8 +391,9 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
|
||||
|
||||
assert.equal(storedMedia.length, 1);
|
||||
assert.equal(updatedFields.length, 1);
|
||||
assert.deepEqual(updatedFields[0], { Sentence: '字幕' });
|
||||
assert.equal(mergeCalls.length, 0);
|
||||
assert.match(updatedFields[0]?.ExpressionAudio ?? '', /^\[sound:audio_\d+\.mp3\]$/);
|
||||
assert.equal(updatedFields[0]?.Sentence, '字幕');
|
||||
assert.equal(mergeCalls.length, 1);
|
||||
});
|
||||
|
||||
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
|
||||
@@ -335,6 +472,7 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
||||
},
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path) => {
|
||||
@@ -383,3 +521,98 @@ test('createSentenceCard relies on Anki progress notification without standalone
|
||||
assert.deepEqual(progressMessages, ['Creating sentence card']);
|
||||
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.']);
|
||||
});
|
||||
|
||||
test('keeping an audio card without media skips generation and preserves the note', async () => {
|
||||
let generatedAudio = false;
|
||||
let deleted = false;
|
||||
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const { service, storedMedia } = 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: '単語' }, Sentence: { value: '' } },
|
||||
},
|
||||
],
|
||||
updateNoteFields: async (noteId, fields) => {
|
||||
updates.push({ noteId, fields });
|
||||
},
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => {
|
||||
deleted = true;
|
||||
},
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => {
|
||||
generatedAudio = true;
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
reviewMediaTiming: async () => ({ action: 'skip-media' }),
|
||||
});
|
||||
|
||||
await service.markLastCardAsAudioCard();
|
||||
|
||||
assert.equal(generatedAudio, false);
|
||||
assert.equal(deleted, false);
|
||||
assert.deepEqual(storedMedia, []);
|
||||
assert.deepEqual(updates, [{ noteId: 42, fields: { Sentence: '字幕' } }]);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
const storedMedia: string[] = [];
|
||||
const requestedProperties: string[] = [];
|
||||
const audioVolumeScales: Array<number | undefined> = [];
|
||||
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
|
||||
|
||||
const deps: CardCreationDeps = {
|
||||
getConfig: () =>
|
||||
@@ -73,17 +74,19 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
},
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (
|
||||
_path,
|
||||
_startTime,
|
||||
_endTime,
|
||||
_audioPadding,
|
||||
startTime,
|
||||
endTime,
|
||||
audioPadding,
|
||||
_audioStreamIndex,
|
||||
_normalizeAudio,
|
||||
volumeScale,
|
||||
) => {
|
||||
audioRanges.push({ start: startTime, end: endTime, padding: audioPadding });
|
||||
audioVolumeScales.push(volumeScale);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
@@ -122,17 +125,15 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
reviewMediaTiming: async () => ({ action: 'confirm', startTime: 11.4, endTime: 14.2 }),
|
||||
};
|
||||
|
||||
const created = await new CardCreationService(deps).createSentenceCard(
|
||||
'字幕',
|
||||
12,
|
||||
14,
|
||||
'Subtitle',
|
||||
);
|
||||
const service = new CardCreationService(deps);
|
||||
const created = await service.createSentenceCard('字幕', 12, 14, 'Subtitle');
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(addedFields[0], {
|
||||
@@ -144,7 +145,19 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
assert.equal(storedMedia.length, 1);
|
||||
assert.deepEqual(requestedProperties, ['volume']);
|
||||
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);
|
||||
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
||||
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
||||
|
||||
deps.reviewMediaTiming = async () => ({ action: 'discard' });
|
||||
assert.equal(await service.createSentenceCard('作らない', 20, 22), false);
|
||||
assert.equal(addedFields.length, 1);
|
||||
|
||||
deps.reviewMediaTiming = async () => ({ action: 'skip-media' });
|
||||
assert.equal(await service.createSentenceCard('メディアなし', 30, 32), true);
|
||||
assert.equal(addedFields.length, 2);
|
||||
assert.equal(storedMedia.length, 1);
|
||||
assert.deepEqual(audioRanges, [{ start: 11.4, end: 14.2, padding: 0 }]);
|
||||
assert.deepEqual(requestedProperties, ['volume']);
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ test('CardCreationService counts locally created sentence cards', async () => {
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
@@ -74,6 +75,7 @@ test('CardCreationService counts locally created sentence cards', async () => {
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
@@ -139,6 +141,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
@@ -173,6 +176,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => {
|
||||
@@ -238,6 +242,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
@@ -272,6 +277,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
recordCardsMinedCallback: () => {
|
||||
@@ -348,6 +354,7 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path) => {
|
||||
@@ -392,6 +399,7 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
@@ -454,6 +462,7 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
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,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
@@ -590,6 +600,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => {
|
||||
@@ -634,6 +645,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
@@ -701,6 +713,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
@@ -733,6 +746,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
@@ -790,6 +804,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
@@ -822,6 +837,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
|
||||
@@ -3,7 +3,13 @@ import {
|
||||
getConfiguredWordFieldName,
|
||||
getPreferredWordValueFromExtractedFields,
|
||||
} 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 type { MediaInput } from '../media-input';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
@@ -55,6 +61,7 @@ interface CardCreationClient {
|
||||
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
||||
findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>;
|
||||
retrieveMediaFile(filename: string): Promise<string>;
|
||||
deleteNotes(noteIds: number[]): Promise<void>;
|
||||
}
|
||||
|
||||
interface CardCreationMediaGenerator {
|
||||
@@ -138,12 +145,16 @@ interface CardCreationDeps {
|
||||
};
|
||||
getFallbackDurationSeconds: () => number;
|
||||
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
||||
removeKnownWordNote: (noteId: number) => void;
|
||||
isUpdateInProgress: () => boolean;
|
||||
setUpdateInProgress: (value: boolean) => void;
|
||||
trackLastAddedNoteId?: (noteId: number) => void;
|
||||
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
|
||||
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
||||
reviewMediaTiming?: (
|
||||
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||
) => Promise<MediaTimingReviewDecision>;
|
||||
}
|
||||
|
||||
export class CardCreationService {
|
||||
@@ -260,9 +271,16 @@ export class CardCreationService {
|
||||
fields,
|
||||
this.deps.getConfig(),
|
||||
);
|
||||
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
|
||||
const config = this.deps.getConfig();
|
||||
const sentenceAudioField = this.deps.resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
|
||||
);
|
||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||
const sentenceField = sentenceCardConfig.sentenceField;
|
||||
const sentenceField = this.deps.resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
config.fields?.sentence ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence,
|
||||
);
|
||||
|
||||
const sentence = blocks.join(' ');
|
||||
const updatedFields: Record<string, string> = {};
|
||||
@@ -284,7 +302,6 @@ export class CardCreationService {
|
||||
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
|
||||
);
|
||||
|
||||
const config = this.deps.getConfig();
|
||||
const generateAudio = shouldGenerateAudio(config);
|
||||
const generateImage = shouldGenerateImage(config);
|
||||
const mediaResolverOptions = this.getMediaResolverOptions();
|
||||
@@ -451,39 +468,66 @@ export class CardCreationService {
|
||||
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 skipMedia = timingDecision.action === 'skip-media';
|
||||
const exactReviewedRange = timingDecision.action === 'confirm';
|
||||
let sentenceText = mpvClient.currentSubText;
|
||||
if (timingDecision.action === 'confirm') {
|
||||
startTime = timingDecision.startTime;
|
||||
endTime = timingDecision.endTime;
|
||||
sentenceText = timingDecision.text?.trim() || sentenceText;
|
||||
}
|
||||
|
||||
const updatedFields: Record<string, string> = {};
|
||||
const errors: string[] = [];
|
||||
let miscInfoFilename: string | null = null;
|
||||
|
||||
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), 'audio');
|
||||
|
||||
const sentenceField = this.deps.getConfig().fields?.sentence;
|
||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||
const sentenceField = sentenceCardConfig.sentenceField;
|
||||
if (sentenceField) {
|
||||
const processedSentence = this.deps.processSentence(mpvClient.currentSubText, fields);
|
||||
const processedSentence = this.deps.processSentence(sentenceText, fields);
|
||||
updatedFields[sentenceField] = processedSentence;
|
||||
}
|
||||
|
||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||
const audioFieldName = sentenceCardConfig.audioField;
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = await this.mediaGenerateAudio(
|
||||
mpvClient.currentVideoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
);
|
||||
if (!skipMedia) {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = await this.mediaGenerateAudio(
|
||||
mpvClient.currentVideoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
exactReviewedRange ? 0 : undefined,
|
||||
);
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
updatedFields[audioFieldName] = `[sound:${audioFilename}]`;
|
||||
miscInfoFilename = audioFilename;
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
updatedFields[audioFieldName] = `[sound:${audioFilename}]`;
|
||||
miscInfoFilename = audioFilename;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate audio for audio card:', (error as Error).message);
|
||||
errors.push('audio');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate audio for audio card:', (error as Error).message);
|
||||
errors.push('audio');
|
||||
}
|
||||
|
||||
if (shouldGenerateImage(this.deps.getConfig())) {
|
||||
if (!skipMedia && shouldGenerateImage(this.deps.getConfig())) {
|
||||
try {
|
||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||
const imageFilename = this.generateImageFilename();
|
||||
@@ -492,6 +536,7 @@ export class CardCreationService {
|
||||
startTime,
|
||||
endTime,
|
||||
animatedLeadInSeconds,
|
||||
exactReviewedRange,
|
||||
);
|
||||
|
||||
const imageField = this.deps.getConfig().fields?.image;
|
||||
@@ -564,9 +609,29 @@ export class CardCreationService {
|
||||
|
||||
try {
|
||||
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 skipMedia = timingDecision.action === 'skip-media';
|
||||
const exactReviewedRange = timingDecision.action === 'confirm';
|
||||
if (timingDecision.action === 'confirm') {
|
||||
startTime = timingDecision.startTime;
|
||||
endTime = timingDecision.endTime;
|
||||
sentence = timingDecision.text?.trim() || sentence;
|
||||
}
|
||||
|
||||
const config = this.deps.getConfig();
|
||||
const generateAudio = shouldGenerateAudio(config);
|
||||
const generateImage = shouldGenerateImage(config);
|
||||
const generateAudio = !skipMedia && shouldGenerateAudio(config);
|
||||
const generateImage = !skipMedia && shouldGenerateImage(config);
|
||||
const mediaResolverOptions = this.getMediaResolverOptions();
|
||||
const videoPath = generateImage
|
||||
? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions)
|
||||
@@ -732,6 +797,7 @@ export class CardCreationService {
|
||||
generateAudio,
|
||||
generateImage,
|
||||
volumeScale,
|
||||
...(exactReviewedRange ? { mediaPaddingSeconds: 0 } : {}),
|
||||
});
|
||||
await this.deps.showNotification(noteId, label, 'media queued');
|
||||
return true;
|
||||
@@ -747,7 +813,12 @@ export class CardCreationService {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = audioSourcePath
|
||||
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
|
||||
? await this.mediaGenerateAudio(
|
||||
audioSourcePath,
|
||||
startTime,
|
||||
endTime,
|
||||
exactReviewedRange ? 0 : undefined,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (audioBuffer) {
|
||||
@@ -765,7 +836,13 @@ export class CardCreationService {
|
||||
if (generateImage) {
|
||||
try {
|
||||
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;
|
||||
if (imageBuffer && imageField) {
|
||||
@@ -806,22 +883,6 @@ export class CardCreationService {
|
||||
}
|
||||
}
|
||||
|
||||
private getResolvedSentenceAudioFieldName(noteInfo: CardCreationNoteInfo): string | null {
|
||||
return (
|
||||
this.deps.resolveNoteFieldName(
|
||||
noteInfo,
|
||||
this.deps.getEffectiveSentenceCardConfig().audioField || 'SentenceAudio',
|
||||
) || this.deps.resolveConfiguredFieldName(noteInfo, this.deps.getConfig().fields?.audio)
|
||||
);
|
||||
}
|
||||
|
||||
private getResolvedSentenceOnlyAudioFieldName(noteInfo: CardCreationNoteInfo): string | null {
|
||||
return this.deps.resolveNoteFieldName(
|
||||
noteInfo,
|
||||
this.deps.getEffectiveSentenceCardConfig().audioField || 'SentenceAudio',
|
||||
);
|
||||
}
|
||||
|
||||
private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo {
|
||||
return {
|
||||
noteId: -1,
|
||||
@@ -833,6 +894,7 @@ export class CardCreationService {
|
||||
videoPath: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
audioPaddingOverride?: number,
|
||||
): Promise<Buffer | null> {
|
||||
const mpvClient = this.deps.getMpvClient();
|
||||
if (!mpvClient) {
|
||||
@@ -843,7 +905,7 @@ export class CardCreationService {
|
||||
videoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
this.deps.getConfig().media?.audioPadding,
|
||||
audioPaddingOverride ?? this.deps.getConfig().media?.audioPadding,
|
||||
resolveAudioStreamIndexForMediaGeneration(
|
||||
videoPath,
|
||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||
@@ -861,13 +923,16 @@ export class CardCreationService {
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
animatedLeadInSeconds = 0,
|
||||
exactReviewedRange = false,
|
||||
): Promise<Buffer | null> {
|
||||
const mpvClient = this.deps.getMpvClient();
|
||||
if (!mpvClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = mpvClient.currentTimePos || 0;
|
||||
const timestamp = exactReviewedRange
|
||||
? startTime + (endTime - startTime) / 2
|
||||
: mpvClient.currentTimePos || 0;
|
||||
|
||||
if (this.deps.getConfig().media?.imageType === 'avif') {
|
||||
let imageStart = startTime;
|
||||
@@ -883,7 +948,7 @@ export class CardCreationService {
|
||||
videoPath,
|
||||
imageStart,
|
||||
imageEnd,
|
||||
this.deps.getConfig().media?.audioPadding,
|
||||
exactReviewedRange ? 0 : this.deps.getConfig().media?.audioPadding,
|
||||
{
|
||||
fps: this.deps.getConfig().media?.animatedFps,
|
||||
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 () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
|
||||
@@ -350,6 +350,17 @@ export class KnownWordCacheManager {
|
||||
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 {
|
||||
this.clearInMemoryState();
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
|
||||
@@ -44,6 +44,7 @@ function createWorkflowHarness() {
|
||||
updates.push({ noteId, fields });
|
||||
},
|
||||
storeMediaFile: async () => undefined,
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
getConfig: () => ({
|
||||
fields: {
|
||||
@@ -61,6 +62,7 @@ function createWorkflowHarness() {
|
||||
kikuFieldGrouping: 'disabled' as const,
|
||||
}),
|
||||
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
|
||||
removeKnownWordNote: (_noteId: number) => undefined,
|
||||
extractFields: (fields: Record<string, { value: string }>) => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
@@ -80,7 +82,6 @@ function createWorkflowHarness() {
|
||||
const names = Object.keys(noteInfo.fields);
|
||||
return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null;
|
||||
},
|
||||
getResolvedSentenceAudioFieldName: () => null,
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next,
|
||||
generateAudioFilename: () => 'audio_1.mp3',
|
||||
@@ -120,6 +121,49 @@ test('NoteUpdateWorkflow updates sentence field and emits notification', async (
|
||||
assert.equal(harness.notifications.length, 1);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow uses configured fields for word-card enrichment with Lapis and Kiku enabled', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.getConfig = () => ({
|
||||
fields: {
|
||||
sentence: 'Context',
|
||||
audio: 'ContextAudio',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: false,
|
||||
},
|
||||
behavior: {},
|
||||
});
|
||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
Sentence: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
Context: { value: '' },
|
||||
ContextAudio: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
harness.deps.generateAudio = async () => Buffer.from('audio');
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Context: 'subtitle-text',
|
||||
ContextAudio: '[sound:audio_1.mp3]',
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
@@ -432,6 +476,7 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
|
||||
harness.deps.getConfig = () => ({
|
||||
fields: {
|
||||
sentence: 'Sentence',
|
||||
audio: 'SentenceAudio',
|
||||
image: 'Picture',
|
||||
miscInfo: 'MiscInfo',
|
||||
},
|
||||
@@ -444,7 +489,6 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
|
||||
});
|
||||
harness.deps.getCurrentSubtitleText = () => 'current primary line';
|
||||
harness.deps.getCurrentSubtitleStart = () => 20;
|
||||
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
|
||||
harness.deps.generateAudio = async (context?: SubtitleMiningContext) => {
|
||||
audioContext = context ?? null;
|
||||
return Buffer.from('audio');
|
||||
@@ -501,6 +545,7 @@ test('NoteUpdateWorkflow snapshots one media range for audio and image without a
|
||||
harness.deps.getConfig = () => ({
|
||||
fields: {
|
||||
sentence: 'Sentence',
|
||||
audio: 'SentenceAudio',
|
||||
image: 'Picture',
|
||||
miscInfo: 'MiscInfo',
|
||||
},
|
||||
@@ -511,7 +556,6 @@ test('NoteUpdateWorkflow snapshots one media range for audio and image without a
|
||||
},
|
||||
behavior: {},
|
||||
});
|
||||
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
|
||||
harness.deps.captureSubtitleMediaContext = () => {
|
||||
captureCalls += 1;
|
||||
return capturedContext;
|
||||
@@ -592,3 +636,141 @@ test('NoteUpdateWorkflow queues media updates when YouTube cache is pending', as
|
||||
assert.equal(queuedUpdates[0]?.context, undefined);
|
||||
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 the word card but skips media after timing review', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
const mediaCalls: string[] = [];
|
||||
const deletedNoteIds: number[][] = [];
|
||||
const queuedUpdates: unknown[] = [];
|
||||
harness.deps.captureSubtitleMediaContext = () => ({
|
||||
source: 'overlay',
|
||||
text: 'subtitle-text',
|
||||
startTime: 4,
|
||||
endTime: 6,
|
||||
});
|
||||
harness.deps.getConfig = () => ({
|
||||
fields: { sentence: 'Sentence', image: 'Picture' },
|
||||
media: { generateAudio: true, generateImage: true },
|
||||
behavior: {},
|
||||
});
|
||||
harness.deps.reviewMediaTiming = async () => ({ action: 'skip-media' });
|
||||
harness.deps.generateAudio = async () => {
|
||||
mediaCalls.push('audio');
|
||||
return Buffer.from('audio');
|
||||
};
|
||||
harness.deps.generateImage = async () => {
|
||||
mediaCalls.push('image');
|
||||
return Buffer.from('image');
|
||||
};
|
||||
harness.deps.queuePendingYoutubeMediaUpdate = async (update) => {
|
||||
queuedUpdates.push(update);
|
||||
return true;
|
||||
};
|
||||
harness.deps.client.deleteNotes = async (noteIds) => {
|
||||
deletedNoteIds.push(noteIds);
|
||||
};
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.deepEqual(mediaCalls, []);
|
||||
assert.deepEqual(queuedUpdates, []);
|
||||
assert.deepEqual(deletedNoteIds, []);
|
||||
assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]);
|
||||
assert.deepEqual(harness.notifications, [{ noteId: 42, label: 'taberu' }]);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow uses the combined review sentence for the card and media range', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
const audioContexts: Array<SubtitleMiningContext | undefined> = [];
|
||||
harness.deps.captureSubtitleMediaContext = () => ({
|
||||
source: 'overlay',
|
||||
text: 'current-line',
|
||||
startTime: 4,
|
||||
endTime: 6,
|
||||
});
|
||||
harness.deps.getConfig = () => ({
|
||||
fields: { sentence: 'Sentence' },
|
||||
media: { generateAudio: true, generateImage: false },
|
||||
behavior: {},
|
||||
});
|
||||
harness.deps.reviewMediaTiming = async () => ({
|
||||
action: 'confirm',
|
||||
startTime: 2,
|
||||
endTime: 7,
|
||||
text: 'previous-line current-line next-line',
|
||||
});
|
||||
harness.deps.generateAudio = async (context) => {
|
||||
audioContexts.push(context);
|
||||
return null;
|
||||
};
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.deepEqual(harness.updates, [
|
||||
{ noteId: 42, fields: { Sentence: 'previous-line current-line next-line' } },
|
||||
]);
|
||||
assert.equal(audioContexts.length, 1);
|
||||
assert.equal(audioContexts[0]?.text, 'previous-line current-line next-line');
|
||||
assert.equal(audioContexts[0]?.startTime, 2);
|
||||
assert.equal(audioContexts[0]?.endTime, 7);
|
||||
assert.equal(audioContexts[0]?.mediaPaddingSeconds, 0);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
const statusMessages: string[] = [];
|
||||
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 { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||
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';
|
||||
|
||||
export interface NoteUpdateWorkflowNoteInfo {
|
||||
@@ -14,11 +19,13 @@ export interface NoteUpdateWorkflowDeps {
|
||||
notesInfo(noteIds: number[]): Promise<unknown>;
|
||||
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
|
||||
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
||||
deleteNotes(noteIds: number[]): Promise<void>;
|
||||
};
|
||||
getConfig: () => {
|
||||
fields?: {
|
||||
word?: string;
|
||||
sentence?: string;
|
||||
audio?: string;
|
||||
image?: string;
|
||||
miscInfo?: string;
|
||||
};
|
||||
@@ -43,6 +50,7 @@ export interface NoteUpdateWorkflowDeps {
|
||||
wordCardKind?: WordCardKind;
|
||||
};
|
||||
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
||||
removeKnownWordNote: (noteId: number) => void;
|
||||
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
||||
findDuplicateNote: (
|
||||
expression: string,
|
||||
@@ -75,7 +83,6 @@ export interface NoteUpdateWorkflowDeps {
|
||||
noteInfo: NoteUpdateWorkflowNoteInfo,
|
||||
...preferredNames: (string | undefined)[]
|
||||
) => string | null;
|
||||
getResolvedSentenceAudioFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo) => string | null;
|
||||
getAnimatedImageLeadInSeconds: (noteInfo: NoteUpdateWorkflowNoteInfo) => Promise<number>;
|
||||
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
|
||||
generateAudioFilename: () => string;
|
||||
@@ -102,6 +109,9 @@ export interface NoteUpdateWorkflowDeps {
|
||||
logWarn: (message: string, ...args: unknown[]) => void;
|
||||
logInfo: (message: string, ...args: unknown[]) => void;
|
||||
logError: (message: string, ...args: unknown[]) => void;
|
||||
reviewMediaTiming?: (
|
||||
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||
) => Promise<MediaTimingReviewDecision>;
|
||||
}
|
||||
|
||||
function normalizeSubtitleContextText(text: string): string {
|
||||
@@ -171,7 +181,6 @@ export class NoteUpdateWorkflow {
|
||||
}
|
||||
|
||||
const noteInfo = notesInfo[0]!;
|
||||
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
||||
const fields = this.deps.extractFields(noteInfo.fields);
|
||||
const config = this.deps.getConfig();
|
||||
|
||||
@@ -198,20 +207,64 @@ export class NoteUpdateWorkflow {
|
||||
const updatedFields: Record<string, string> = {};
|
||||
let updatePerformed = false;
|
||||
let miscInfoFilename: string | null = null;
|
||||
const sentenceField = sentenceCardConfig.sentenceField;
|
||||
const configuredSentenceField =
|
||||
config.fields?.sentence ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence;
|
||||
const sentenceField = this.deps.resolveConfiguredFieldName(noteInfo, configuredSentenceField);
|
||||
const subtitleMiningContext = this.consumeMatchingSubtitleMiningContext(
|
||||
fields,
|
||||
sentenceField,
|
||||
config.fields?.sentence,
|
||||
sentenceField ?? configuredSentenceField,
|
||||
configuredSentenceField,
|
||||
);
|
||||
// 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
|
||||
// timings per generator clips whichever line is on screen when each one starts.
|
||||
const mediaTimingContext =
|
||||
let mediaTimingContext =
|
||||
subtitleMiningContext ?? this.deps.captureSubtitleMediaContext?.() ?? null;
|
||||
let skipMedia = false;
|
||||
let reviewedSentenceText: string | undefined;
|
||||
const noteLabel = hasExpressionText ? expressionText : noteId;
|
||||
|
||||
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
|
||||
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') {
|
||||
reviewedSentenceText = timingDecision.text?.trim() || undefined;
|
||||
mediaTimingContext = {
|
||||
...mediaTimingContext,
|
||||
...(reviewedSentenceText !== undefined ? { text: reviewedSentenceText } : {}),
|
||||
startTime: timingDecision.startTime,
|
||||
endTime: timingDecision.endTime,
|
||||
mediaPaddingSeconds: 0,
|
||||
};
|
||||
} else if (timingDecision.action === 'skip-media') {
|
||||
skipMedia = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
||||
|
||||
const currentSubtitleText =
|
||||
reviewedSentenceText ?? subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
|
||||
if (sentenceField && currentSubtitleText) {
|
||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||
updatedFields[sentenceField] = processedSentence;
|
||||
@@ -239,8 +292,8 @@ export class NoteUpdateWorkflow {
|
||||
}
|
||||
}
|
||||
|
||||
const generateAudio = config.media?.generateAudio !== false;
|
||||
const generateImage = config.media?.generateImage !== false;
|
||||
const generateAudio = !skipMedia && config.media?.generateAudio !== false;
|
||||
const generateImage = !skipMedia && config.media?.generateImage !== false;
|
||||
const mediaCacheQueued =
|
||||
(generateAudio || generateImage) && this.deps.queuePendingYoutubeMediaUpdate
|
||||
? await this.deps.queuePendingYoutubeMediaUpdate({
|
||||
@@ -258,7 +311,10 @@ export class NoteUpdateWorkflow {
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
const sentenceAudioField = this.deps.getResolvedSentenceAudioFieldName(noteInfo);
|
||||
const sentenceAudioField = this.deps.resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
|
||||
);
|
||||
if (sentenceAudioField) {
|
||||
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
|
||||
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
|
||||
|
||||
@@ -31,7 +31,6 @@ function createDeps(
|
||||
getCachedMediaPath: async () => null,
|
||||
shouldRequireRemoteMediaCache: () => true,
|
||||
getSubtitleMediaRange: () => ({ startTime: 1, endTime: 2 }),
|
||||
getResolvedSentenceAudioFieldName: () => 'SentenceAudio',
|
||||
resolveConfiguredFieldName: () => 'Picture',
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
@@ -133,7 +132,7 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
|
||||
noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
ExpressionAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
})),
|
||||
@@ -144,13 +143,16 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
},
|
||||
getConfig: () => ({ media: {}, fields: { image: 'Picture' } }) as AnkiConnectConfig,
|
||||
getConfig: () =>
|
||||
({ media: {}, fields: { audio: 'ExpressionAudio', image: 'Picture' } }) as AnkiConnectConfig,
|
||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
||||
preferredNames.find((name) => name && name in noteInfo.fields) ?? null,
|
||||
});
|
||||
const queue = new PendingYoutubeMediaQueue(deps);
|
||||
|
||||
const queued = await queue.queueFromNote({
|
||||
noteId: 42,
|
||||
noteInfo: { noteId: 42, fields: {} },
|
||||
noteInfo: { noteId: 42, fields: { ExpressionAudio: { value: '' } } },
|
||||
label: 'demo',
|
||||
});
|
||||
await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||
@@ -158,7 +160,8 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
|
||||
assert.equal(queued, true);
|
||||
assert.equal(updatedNotes.length, 1);
|
||||
assert.equal(storedMedia.length, 2);
|
||||
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio\.mp3\]$/);
|
||||
assert.match(updatedNotes[0]?.fields.ExpressionAudio ?? '', /^\[sound:audio\.mp3\]$/);
|
||||
assert.equal('SentenceAudio' in (updatedNotes[0]?.fields ?? {}), false);
|
||||
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image\.webp">$/);
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ export interface PendingYoutubeMediaQueueDeps {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
getResolvedSentenceAudioFieldName: (noteInfo: PendingYoutubeMediaNoteInfo) => string | null;
|
||||
resolveConfiguredFieldName: (
|
||||
noteInfo: PendingYoutubeMediaNoteInfo,
|
||||
...preferredNames: (string | undefined)[]
|
||||
@@ -136,7 +135,7 @@ export class PendingYoutubeMediaQueue {
|
||||
startTime: mediaRange.startTime,
|
||||
endTime: mediaRange.endTime,
|
||||
label: job.label,
|
||||
audioFieldName: this.deps.getResolvedSentenceAudioFieldName(job.noteInfo) ?? undefined,
|
||||
audioFieldName: this.resolveConfiguredAudioFieldName(job.noteInfo) ?? undefined,
|
||||
imageFieldName:
|
||||
this.deps.resolveConfiguredFieldName(
|
||||
job.noteInfo,
|
||||
@@ -148,6 +147,9 @@ export class PendingYoutubeMediaQueue {
|
||||
generateAudio: shouldGenerateAudio(config),
|
||||
generateImage: shouldGenerateImage(config),
|
||||
volumeScale,
|
||||
...(job.context?.mediaPaddingSeconds !== undefined
|
||||
? { mediaPaddingSeconds: job.context.mediaPaddingSeconds }
|
||||
: {}),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -247,6 +249,14 @@ export class PendingYoutubeMediaQueue {
|
||||
return matched;
|
||||
}
|
||||
|
||||
private resolveConfiguredAudioFieldName(noteInfo: PendingYoutubeMediaNoteInfo): string | null {
|
||||
const config = this.deps.getConfig();
|
||||
return this.deps.resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
|
||||
);
|
||||
}
|
||||
|
||||
private async applyUpdate(
|
||||
job: PendingYoutubeMediaUpdate,
|
||||
cachedPath: string,
|
||||
@@ -275,7 +285,7 @@ export class PendingYoutubeMediaQueue {
|
||||
cachedMediaInput,
|
||||
job.startTime,
|
||||
job.endTime,
|
||||
config.media?.audioPadding,
|
||||
job.mediaPaddingSeconds ?? config.media?.audioPadding,
|
||||
undefined,
|
||||
config.media?.normalizeAudio !== false,
|
||||
job.volumeScale,
|
||||
@@ -283,7 +293,7 @@ export class PendingYoutubeMediaQueue {
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
const audioField =
|
||||
job.audioFieldName || this.deps.getResolvedSentenceAudioFieldName(noteInfo) || null;
|
||||
job.audioFieldName || this.resolveConfiguredAudioFieldName(noteInfo) || null;
|
||||
if (audioField) {
|
||||
const existingAudio = noteInfo.fields[audioField]?.value || '';
|
||||
mediaFields[audioField] = this.deps.mergeFieldValue(
|
||||
@@ -309,6 +319,7 @@ export class PendingYoutubeMediaQueue {
|
||||
job.startTime,
|
||||
job.endTime,
|
||||
animatedLeadInSeconds,
|
||||
job.mediaPaddingSeconds,
|
||||
);
|
||||
if (imageBuffer) {
|
||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||
@@ -369,6 +380,7 @@ export class PendingYoutubeMediaQueue {
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
animatedLeadInSeconds = 0,
|
||||
mediaPaddingSeconds?: number,
|
||||
): Promise<Buffer | null> {
|
||||
const config = this.deps.getConfig();
|
||||
if (config.media?.imageType === 'avif') {
|
||||
@@ -376,7 +388,7 @@ export class PendingYoutubeMediaQueue {
|
||||
videoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
config.media?.audioPadding,
|
||||
mediaPaddingSeconds ?? config.media?.audioPadding,
|
||||
{
|
||||
fps: config.media?.animatedFps,
|
||||
maxWidth: config.media?.animatedMaxWidth,
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface PendingYoutubeMediaUpdate {
|
||||
generateAudio: boolean;
|
||||
generateImage: boolean;
|
||||
volumeScale?: number;
|
||||
mediaPaddingSeconds?: number;
|
||||
}
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
|
||||
@@ -2181,6 +2181,7 @@ test('runtime options registry is centralized', () => {
|
||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||
assert.deepEqual(ids, [
|
||||
'anki.autoUpdateNewCards',
|
||||
'anki.mediaReviewTiming',
|
||||
'subtitle.annotation.knownWords.highlightEnabled',
|
||||
'subtitle.annotation.knownWords.maturityEnabled',
|
||||
'subtitle.annotation.nPlusOne',
|
||||
|
||||
@@ -54,6 +54,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
syncAnimatedImageToWordAudio: true,
|
||||
normalizeAudio: true,
|
||||
mirrorMpvVolume: true,
|
||||
reviewTiming: false,
|
||||
audioPadding: 0,
|
||||
fallbackDuration: 3.0,
|
||||
maxMediaDuration: 30,
|
||||
|
||||
@@ -196,6 +196,14 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
"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.',
|
||||
runtime: runtimeOptionById.get('anki.mediaReviewTiming'),
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.generateImage',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -19,6 +19,20 @@ export function buildRuntimeOptionRegistry(
|
||||
behavior: { autoUpdateNewCards: value === true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'anki.mediaReviewTiming',
|
||||
path: 'ankiConnect.media.reviewTiming',
|
||||
label: 'Review Media Timing',
|
||||
scope: 'ankiConnect',
|
||||
valueType: 'boolean',
|
||||
allowedValues: [true, false],
|
||||
defaultValue: defaultConfig.ankiConnect.media.reviewTiming,
|
||||
requiresRestart: false,
|
||||
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
|
||||
toAnkiPatch: (value) => ({
|
||||
media: { reviewTiming: value === true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'subtitle.annotation.knownWords.highlightEnabled',
|
||||
path: 'ankiConnect.knownWords.highlightEnabled',
|
||||
|
||||
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
title: 'AnkiConnect Integration',
|
||||
description: ['Automatic Anki updates and media generation options.'],
|
||||
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.',
|
||||
'Most other AnkiConnect settings still require restart.',
|
||||
],
|
||||
|
||||
@@ -21,6 +21,34 @@ function makeContext(ankiConnect: unknown): {
|
||||
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', () => {
|
||||
const { context, warnings } = makeContext({
|
||||
nPlusOne: { highlightEnabled: true },
|
||||
|
||||
@@ -19,6 +19,7 @@ export function applyModernMediaResolution(
|
||||
'syncAnimatedImageToWordAudio',
|
||||
'normalizeAudio',
|
||||
'mirrorMpvVolume',
|
||||
'reviewTiming',
|
||||
] as const) {
|
||||
applyModernValue(
|
||||
context,
|
||||
@@ -128,18 +129,28 @@ export function applyModernMediaResolution(
|
||||
'Expected non-negative number.',
|
||||
);
|
||||
|
||||
for (const key of ['fallbackDuration', 'maxMediaDuration'] as const) {
|
||||
applyModernValue(
|
||||
context,
|
||||
media,
|
||||
key,
|
||||
`ankiConnect.media.${key}`,
|
||||
asPositiveNumber,
|
||||
DEFAULT_CONFIG.ankiConnect.media[key],
|
||||
(value) => {
|
||||
context.resolved.ankiConnect.media[key] = value;
|
||||
},
|
||||
'Expected positive number.',
|
||||
);
|
||||
}
|
||||
applyModernValue(
|
||||
context,
|
||||
media,
|
||||
'fallbackDuration',
|
||||
'ankiConnect.media.fallbackDuration',
|
||||
asPositiveNumber,
|
||||
DEFAULT_CONFIG.ankiConnect.media.fallbackDuration,
|
||||
(value) => {
|
||||
context.resolved.ankiConnect.media.fallbackDuration = value;
|
||||
},
|
||||
'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.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -352,6 +352,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'ankiConnect.media.mirrorMpvVolume',
|
||||
'ankiConnect.media.reviewTiming',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
'ankiConnect.knownWords.addMinedWordsImmediately',
|
||||
|
||||
@@ -244,6 +244,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'mpv.aniskipEnabled': 'Enable AniSkip',
|
||||
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
||||
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
|
||||
'ankiConnect.media.reviewTiming': 'Review Media Timing',
|
||||
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
||||
};
|
||||
|
||||
@@ -694,6 +695,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
path === 'ankiConnect.ai.enabled' ||
|
||||
path === 'ankiConnect.media.normalizeAudio' ||
|
||||
path === 'ankiConnect.media.mirrorMpvVolume' ||
|
||||
path === 'ankiConnect.media.reviewTiming' ||
|
||||
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
|
||||
path === 'ankiConnect.knownWords.highlightEnabled' ||
|
||||
path === 'ankiConnect.knownWords.refreshMinutes' ||
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
dismissOverlayNotification?: (id: string) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
@@ -166,6 +167,7 @@ export function registerAnkiJimakuIpcRuntime(
|
||||
options.getCachedMediaPath,
|
||||
options.shouldRequireRemoteMediaCache,
|
||||
options.getYoutubeMediaSourceUrl,
|
||||
options.dismissOverlayNotification,
|
||||
);
|
||||
integration.start();
|
||||
options.setAnkiIntegration(integration);
|
||||
|
||||
@@ -218,3 +218,18 @@ test('removeLiveGlyphFragmentLines leaves ordinary short lines alone', () => {
|
||||
const text = 'え\nはい。\nそうだな';
|
||||
assert.equal(removeLiveGlyphFragmentLines(text), text);
|
||||
});
|
||||
|
||||
test('normalizePlainSubtitleText folds cue-boundary blank lines for text consumers', () => {
|
||||
// The display layer splits on the blank line before normalizing; everyone else --
|
||||
// tokenizer, cache key, dedup gate, mined sentence -- wants the plain line form.
|
||||
assert.equal(
|
||||
normalizePlainSubtitleText('\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee'),
|
||||
'\u4e00\u884c\u76ee\n\u4e8c\u884c\u76ee',
|
||||
);
|
||||
assert.equal(
|
||||
normalizePlainSubtitleText('\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee', {
|
||||
collapseLineBreaks: true,
|
||||
}),
|
||||
'\u4e00\u884c\u76ee \u4e8c\u884c\u76ee',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -153,6 +153,10 @@ export function normalizePlainSubtitleText(
|
||||
);
|
||||
if (collapseLineBreaks) {
|
||||
normalized = normalized.replace(/\n/g, ' ').replace(/\s+/g, ' ');
|
||||
} else {
|
||||
// Simultaneous cues reach the display layer separated by a blank line; every other
|
||||
// consumer wants the plain one-break-per-line form.
|
||||
normalized = normalized.replace(/\n{2,}/g, '\n');
|
||||
}
|
||||
|
||||
return trim ? normalized.trim() : normalized;
|
||||
|
||||
@@ -33,6 +33,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
|
||||
next.ankiConnect.deck = 'Mining';
|
||||
next.ankiConnect.media.normalizeAudio = !prev.ankiConnect.media.normalizeAudio;
|
||||
next.ankiConnect.media.mirrorMpvVolume = !prev.ankiConnect.media.mirrorMpvVolume;
|
||||
next.ankiConnect.media.reviewTiming = !prev.ankiConnect.media.reviewTiming;
|
||||
next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards;
|
||||
next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
|
||||
next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5;
|
||||
@@ -69,6 +70,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'ankiConnect.media.mirrorMpvVolume',
|
||||
'ankiConnect.media.reviewTiming',
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
|
||||
@@ -70,6 +70,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'ankiConnect.media.mirrorMpvVolume',
|
||||
'ankiConnect.media.reviewTiming',
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
|
||||
@@ -648,6 +648,83 @@ test('registerIpcHandlers exposes playback window activation request', async ()
|
||||
assert.deepEqual(calls, ['activate']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers accepts the keep-without-media timing decision', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const requests: unknown[] = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
resolveMediaTimingReview: async (request) => {
|
||||
requests.push(request);
|
||||
return { ok: true };
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const handler = handlers.handle.get(IPC_CHANNELS.request.mediaTimingReviewResolve);
|
||||
assert.ok(handler);
|
||||
assert.deepEqual(
|
||||
await handler!({}, { reviewId: 'review-1', decision: { action: 'skip-media' } }),
|
||||
{ ok: true },
|
||||
);
|
||||
assert.deepEqual(requests, [{ reviewId: 'review-1', decision: { action: 'skip-media' } }]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers validates and forwards combined timing review text', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const requests: unknown[] = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
resolveMediaTimingReview: async (request) => {
|
||||
requests.push(request);
|
||||
return { ok: true };
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const handler = handlers.handle.get(IPC_CHANNELS.request.mediaTimingReviewResolve);
|
||||
assert.ok(handler);
|
||||
assert.deepEqual(
|
||||
await handler!(
|
||||
{},
|
||||
{
|
||||
reviewId: 'review-1',
|
||||
decision: {
|
||||
action: 'confirm',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
text: '前の行 対象の行',
|
||||
},
|
||||
},
|
||||
),
|
||||
{ ok: true },
|
||||
);
|
||||
assert.deepEqual(requests, [
|
||||
{
|
||||
reviewId: 'review-1',
|
||||
decision: {
|
||||
action: 'confirm',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
text: '前の行 対象の行',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
await handler!(
|
||||
{},
|
||||
{
|
||||
reviewId: 'review-1',
|
||||
decision: { action: 'confirm', startTime: 10, endTime: 12, text: ' ' },
|
||||
},
|
||||
),
|
||||
{ ok: false, message: 'Timing review is unavailable.' },
|
||||
);
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -19,6 +19,13 @@ import type {
|
||||
YoutubePickerResolveRequest,
|
||||
YoutubePickerResolveResult,
|
||||
} from '../../types';
|
||||
import type {
|
||||
MediaTimingReviewActionResult,
|
||||
MediaTimingReviewPreviewRequest,
|
||||
MediaTimingReviewResolveRequest,
|
||||
MediaTimingReviewWaveformRequest,
|
||||
MediaTimingReviewWaveformResult,
|
||||
} from '../../types/anki';
|
||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
parseMpvCommand,
|
||||
@@ -99,6 +106,16 @@ export interface IpcServiceDeps {
|
||||
onYoutubePickerResolve: (
|
||||
request: YoutubePickerResolveRequest,
|
||||
) => 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;
|
||||
getRuntimeOptions: () => unknown;
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
@@ -222,6 +239,72 @@ function parseOverlayNotificationActionPayload(
|
||||
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 === 'skip-media' ||
|
||||
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) &&
|
||||
(decisionRecord.text === undefined ||
|
||||
(typeof decisionRecord.text === 'string' && decisionRecord.text.trim().length > 0))
|
||||
) {
|
||||
return {
|
||||
reviewId: record.reviewId,
|
||||
decision: {
|
||||
action: 'confirm',
|
||||
startTime: decisionRecord.startTime,
|
||||
endTime: decisionRecord.endTime,
|
||||
...(decisionRecord.text === undefined ? {} : { text: decisionRecord.text }),
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface IpcDepsRuntimeOptions {
|
||||
getMainWindow: () => WindowLike | null;
|
||||
getVisibleOverlayVisibility: () => boolean;
|
||||
@@ -278,6 +361,10 @@ export interface IpcDepsRuntimeOptions {
|
||||
onYoutubePickerResolve: (
|
||||
request: YoutubePickerResolveRequest,
|
||||
) => Promise<YoutubePickerResolveResult>;
|
||||
previewMediaTimingReview?: IpcServiceDeps['previewMediaTimingReview'];
|
||||
getMediaTimingReviewWaveform?: IpcServiceDeps['getMediaTimingReviewWaveform'];
|
||||
stopMediaTimingReviewPreview?: IpcServiceDeps['stopMediaTimingReviewPreview'];
|
||||
resolveMediaTimingReview?: IpcServiceDeps['resolveMediaTimingReview'];
|
||||
getAnkiConnectStatus: () => boolean;
|
||||
getRuntimeOptions: () => unknown;
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
@@ -371,6 +458,10 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
options.activatePlaybackWindowForOverlayInteraction ?? (() => false),
|
||||
runSubsyncManual: options.runSubsyncManual,
|
||||
onYoutubePickerResolve: options.onYoutubePickerResolve,
|
||||
previewMediaTimingReview: options.previewMediaTimingReview,
|
||||
getMediaTimingReviewWaveform: options.getMediaTimingReviewWaveform,
|
||||
stopMediaTimingReviewPreview: options.stopMediaTimingReviewPreview,
|
||||
resolveMediaTimingReview: options.resolveMediaTimingReview,
|
||||
getAnkiConnectStatus: options.getAnkiConnectStatus,
|
||||
getRuntimeOptions: options.getRuntimeOptions,
|
||||
setRuntimeOption: options.setRuntimeOption,
|
||||
@@ -498,6 +589,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, () => {
|
||||
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,315 @@
|
||||
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(
|
||||
// macOS limits Unix socket paths to 104 bytes, while its temp directory can be long.
|
||||
process.platform === 'darwin' ? '/tmp' : 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);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ type CreateAnkiIntegrationArgs = {
|
||||
mpvClient: { send?: (payload: { command: string[] }) => void };
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
dismissOverlayNotification?: (id: string) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
@@ -74,6 +75,7 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
|
||||
args.getCachedMediaPath,
|
||||
args.shouldRequireRemoteMediaCache,
|
||||
args.getYoutubeMediaSourceUrl,
|
||||
args.dismissOverlayNotification,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,6 +139,7 @@ export function initializeOverlayRuntime(
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
dismissOverlayNotification?: (id: string) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
@@ -177,6 +180,7 @@ export function initializeOverlayAnkiIntegration(options: {
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
dismissOverlayNotification?: (id: string) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
@@ -219,6 +223,7 @@ export function initializeOverlayAnkiIntegration(options: {
|
||||
mpvClient,
|
||||
showDesktopNotification: options.showDesktopNotification,
|
||||
showOverlayNotification: options.showOverlayNotification,
|
||||
dismissOverlayNotification: options.dismissOverlayNotification,
|
||||
createFieldGroupingCallback: options.createFieldGroupingCallback,
|
||||
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
|
||||
...(options.getCachedMediaPath ? { getCachedMediaPath: options.getCachedMediaPath } : {}),
|
||||
|
||||
@@ -1442,7 +1442,7 @@ const eventsHeader = [
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
];
|
||||
|
||||
test('parseSubtitleCues keeps a tall CC-style dialogue block publishable, not a fragment grid', () => {
|
||||
test('parseSubtitleCues keeps tall CC-style base dialogue publishable after removing furigana', () => {
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(212,383)\\fscx50\\fscy50}たき',
|
||||
@@ -1451,9 +1451,94 @@ test('parseSubtitleCues keeps a tall CC-style dialogue block publishable, not a
|
||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(192,497)}お前…{\\fscx50} {\\fscx100}燈をバンドに誘ったの?',
|
||||
].join('\n');
|
||||
|
||||
const cue = parseSubtitleCues(content, 'test.ass')[0];
|
||||
assert.equal(cue?.text, 'たき(立希)ともりお前… 燈をバンドに誘ったの?');
|
||||
assert.equal(cue?.assLayout?.kind, 'positioned');
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
['(立希)', 'お前… 燈をバンドに誘ったの?'],
|
||||
);
|
||||
assert.deepEqual(cues[0]?.assFurigana, ['たき']);
|
||||
assert.deepEqual(cues[1]?.assFurigana, ['ともり']);
|
||||
assert.ok(cues.every((cue) => cue.assLayout?.kind === 'positioned'));
|
||||
});
|
||||
|
||||
test('parseSubtitleCues removes half-size positioned furigana from broadcast captions', () => {
|
||||
const content = [
|
||||
'[Script Info]',
|
||||
'PlayResY: 540',
|
||||
'',
|
||||
...eventsHeader,
|
||||
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(192,77)\\fscx50}({\\fscx100}山田{\\fscx50}){\\fscx100}ごめん{\\fscx50} {\\fscx100}結局{\\fscx50} {\\fscx100}ぬれたな{\\fscx50}。',
|
||||
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,113)\\fscx50\\fscy50}だいじょうぶ',
|
||||
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)}大丈夫{\\fscx50}。',
|
||||
'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(232,407)\\fscx50}({\\fscx100}山田の母{\\fscx50}){\\fscx100}ほんなら',
|
||||
'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(232,443)\\fscx50\\fscy50}かく',
|
||||
'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(312,443)\\fscx50\\fscy50}ちょぞう',
|
||||
'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(232,497)}隠し貯蔵のミルクまんじゅう➡',
|
||||
'Dialogue: 0,0:04:00.00,0:04:03.00,Default,,0,0,0,,{\\pos(232,443)\\fscx50\\fscy50}ぜったい ちが',
|
||||
'Dialogue: 0,0:04:00.00,0:04:03.00,Default,,0,0,0,,{\\pos(232,497)}絶対違う',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'polar-opposites-s02e08.ass');
|
||||
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
[
|
||||
'(山田)ごめん 結局 ぬれたな。',
|
||||
'大丈夫。',
|
||||
'(山田の母)ほんなら',
|
||||
'隠し貯蔵のミルクまんじゅう➡',
|
||||
'絶対違う',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(cues[1]?.assFurigana, ['だいじょうぶ']);
|
||||
assert.deepEqual(cues[3]?.assFurigana, ['かく', 'ちょぞう']);
|
||||
assert.deepEqual(cues[4]?.assFurigana, ['ぜったい ちが']);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues scales furigana geometry by PlayResY', () => {
|
||||
const content = [
|
||||
'[Script Info]',
|
||||
'PlayResY: 1080',
|
||||
'',
|
||||
...eventsHeader,
|
||||
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(1104,226)\\fscx50\\fscy50}だいじょうぶ',
|
||||
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(1104,334)}大丈夫{\\fscx50}。',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
['大丈夫。'],
|
||||
);
|
||||
assert.deepEqual(cues[0]?.assFurigana, ['だいじょうぶ']);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues preserves small kana without a matching kanji base caption', () => {
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(200,200)\\fscx50\\fscy50}ひそひそ',
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(200,254)}ordinary dialogue',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
|
||||
['ひそひそ', 'ordinary dialogue'],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues preserves small kana horizontally separated from a kanji caption', () => {
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(800,200)\\fscx50\\fscy50}ひそひそ',
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(200,254)}漢字',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
assert.match(cues.map((cue) => cue.text).join('\n'), /ひそひそ/);
|
||||
assert.deepEqual(
|
||||
cues.flatMap((cue) => cue.assFurigana ?? []),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues marks re-shown countdown frames as a fragment grid', () => {
|
||||
@@ -1927,9 +2012,7 @@ test('parseSubtitleCues does not double a line rendered whole beside its glyph s
|
||||
['わ', 1022],
|
||||
['ね', 1064],
|
||||
] as const;
|
||||
const wholeLine = glyphs
|
||||
.map(([glyph]) => `{\\an5\\fad(300,500)\\pos(960,50)}${glyph}`)
|
||||
.join('');
|
||||
const wholeLine = glyphs.map(([glyph]) => `{\\an5\\fad(300,500)\\pos(960,50)}${glyph}`).join('');
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
`Dialogue: 1,0:00:17.29,0:00:18.99,OP - JP,,0,0,0,,${wholeLine}`,
|
||||
@@ -1952,7 +2035,7 @@ test('parseSubtitleCues drops a wall of near-invisible positioned texture string
|
||||
// faint translation is one or two events and stays published.
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
'Dialogue: 90,0:00:12.66,0:00:14.91,Default,,0,0,0,,We\'ll play as a band, and then...',
|
||||
"Dialogue: 90,0:00:12.66,0:00:14.91,Default,,0,0,0,,We'll play as a band, and then...",
|
||||
...Array.from(
|
||||
{ length: 12 },
|
||||
(_, index) =>
|
||||
@@ -1997,3 +2080,25 @@ test('parseSubtitleCues keeps hidden events hidden when a transform animates an
|
||||
['grows into view', 'wipes into view'],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseAssCues records the vertical band from style alignment, overrides, and \\pos', () => {
|
||||
const ass = [
|
||||
'[Script Info]',
|
||||
'PlayResY: 720',
|
||||
'',
|
||||
'[V4+ Styles]',
|
||||
'Format: Name, Fontname, Fontsize, PrimaryColour, Bold, Alignment, MarginV, Encoding',
|
||||
'Style: Bottom,Arial,54,&H00FFFFFF,0,2,30,1',
|
||||
'Style: TopSong,Arial,54,&H00FFFFFF,0,9,12,1',
|
||||
'',
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:03.00,Bottom,,0,0,0,,\u4e0b\u306e\u30bb\u30ea\u30d5',
|
||||
'Dialogue: 0,0:00:01.00,0:00:03.00,TopSong,,0,0,0,,\u6b4c\u8a5e\u306e\u884c',
|
||||
'Dialogue: 0,0:00:01.00,0:00:03.00,Bottom,,0,0,0,,{\\an8}\u4e0a\u66f8\u304d\u306e\u884c',
|
||||
'Dialogue: 0,0:00:01.00,0:00:03.00,Bottom,,0,0,0,,{\\pos(640,20)}\u770b\u677f\u306e\u884c',
|
||||
].join('\n');
|
||||
|
||||
const bands = parseAssCues(ass).map((cue) => cue.assLayout?.verticalBand);
|
||||
assert.deepEqual(bands, ['bottom', 'top', 'top', 'top']);
|
||||
});
|
||||
|
||||
@@ -10,15 +10,29 @@ import {
|
||||
} from './ass-text';
|
||||
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
|
||||
|
||||
/** Vertical third of the screen a cue is authored to occupy. */
|
||||
export type AssVerticalBand = 'top' | 'middle' | 'bottom';
|
||||
|
||||
export type AssCueLayout =
|
||||
| { kind: 'positioned'; sourceOrder: number; y: number }
|
||||
| { kind: 'fragment-grid'; sourceOrder: number }
|
||||
| { kind: 'source-order'; sourceOrder: number };
|
||||
| {
|
||||
kind: 'positioned';
|
||||
sourceOrder: number;
|
||||
x?: number;
|
||||
y: number;
|
||||
verticalBand?: AssVerticalBand;
|
||||
}
|
||||
| { kind: 'fragment-grid'; sourceOrder: number; verticalBand?: AssVerticalBand }
|
||||
| { kind: 'source-order'; sourceOrder: number; verticalBand?: AssVerticalBand };
|
||||
|
||||
export interface SubtitleCue {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
/**
|
||||
* ASS ruby text removed from the published cue. Kept only so live `sub-text` matching
|
||||
* can account for the extra lines mpv still reports from the source track.
|
||||
*/
|
||||
assFurigana?: readonly string[];
|
||||
/** How a complete line was recovered from generated ASS animation events. */
|
||||
source?: 'canonical-ass' | 'reconstructed-ass';
|
||||
/**
|
||||
@@ -97,10 +111,17 @@ function sanitizeAssCueText(text: string): string {
|
||||
return removeAssControlDebrisLines(decodeSubtitleCueText(text)).trim();
|
||||
}
|
||||
|
||||
function attachAssLayout<T extends SubtitleCue>(cue: T, assLayout: AssCueLayout | undefined): T {
|
||||
function attachAssMetadata<T extends SubtitleCue>(
|
||||
cue: T,
|
||||
assLayout: AssCueLayout | undefined,
|
||||
assFurigana: readonly string[] | undefined,
|
||||
): T {
|
||||
if (assLayout) {
|
||||
Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false });
|
||||
}
|
||||
if (assFurigana?.length) {
|
||||
Object.defineProperty(cue, 'assFurigana', { value: assFurigana, enumerable: false });
|
||||
}
|
||||
return cue;
|
||||
}
|
||||
|
||||
@@ -115,6 +136,7 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
||||
animationEndTime,
|
||||
style,
|
||||
assLayout,
|
||||
assFurigana,
|
||||
}) => {
|
||||
const common = {
|
||||
startTime,
|
||||
@@ -122,7 +144,7 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
||||
text,
|
||||
};
|
||||
if (source === 'reconstructed-ass') {
|
||||
return attachAssLayout(
|
||||
return attachAssMetadata(
|
||||
{
|
||||
...common,
|
||||
source,
|
||||
@@ -131,11 +153,13 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
||||
assStyle: style,
|
||||
},
|
||||
assLayout,
|
||||
assFurigana,
|
||||
);
|
||||
}
|
||||
return attachAssLayout(
|
||||
return attachAssMetadata(
|
||||
source ? { ...common, source, animationStartTime, animationEndTime } : common,
|
||||
assLayout,
|
||||
assFurigana,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -481,9 +505,7 @@ function structuralOverrideSignature(cue: AnnotatedSubtitleCue): string {
|
||||
let signature = structuralSignatureCache.get(cue);
|
||||
if (signature === undefined) {
|
||||
const names = new Set(
|
||||
cue.overrides.map(
|
||||
(command) => `${command.animated ? '~' : ''}${command.name.toLowerCase()}`,
|
||||
),
|
||||
cue.overrides.map((command) => `${command.animated ? '~' : ''}${command.name.toLowerCase()}`),
|
||||
);
|
||||
signature = [...names].sort().join(',');
|
||||
structuralSignatureCache.set(cue, signature);
|
||||
@@ -544,9 +566,7 @@ function buildCoalescedCopy(members: readonly AnnotatedSubtitleCue[]): Annotated
|
||||
* while the anchor says one glyph. Merging each stack into a single presence spanning
|
||||
* the union window lets timing clusters see the authored line instead of its phases.
|
||||
*/
|
||||
function coalesceAssAnchorCopies(
|
||||
events: readonly AnnotatedSubtitleCue[],
|
||||
): AnnotatedSubtitleCue[] {
|
||||
function coalesceAssAnchorCopies(events: readonly AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
|
||||
const buckets = new Map<string, number[]>();
|
||||
const anchorPoints: (AssFragmentPosition[] | null)[] = events.map(() => null);
|
||||
events.forEach((event, index) => {
|
||||
@@ -1362,8 +1382,7 @@ function isRepeatedGlyphText(cue: AnnotatedSubtitleCue): boolean {
|
||||
|
||||
function isClippedRepeatedGlyphFragment(cue: AnnotatedSubtitleCue): boolean {
|
||||
return (
|
||||
isRepeatedGlyphText(cue) &&
|
||||
(hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip'))
|
||||
isRepeatedGlyphText(cue) && (hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip'))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2021,6 +2040,9 @@ function recoverCanonicalAssEvents({
|
||||
const animationEndTime = latestEndTime(generatedEvents, candidate.endTime);
|
||||
const startTime = kind === 'comment' ? candidate.startTime : animationStartTime;
|
||||
const endTime = kind === 'comment' ? candidate.endTime : animationEndTime;
|
||||
const assFurigana = [
|
||||
...new Set([candidate, ...generatedEvents].flatMap((cue) => cue.assFurigana ?? [])),
|
||||
];
|
||||
const recoveredCue: AnnotatedSubtitleCue = {
|
||||
...candidate,
|
||||
startTime,
|
||||
@@ -2028,6 +2050,7 @@ function recoverCanonicalAssEvents({
|
||||
animationStartTime,
|
||||
animationEndTime,
|
||||
source: 'canonical-ass',
|
||||
...(assFurigana.length === 0 ? {} : { assFurigana }),
|
||||
};
|
||||
recovered.push(recoveredCue);
|
||||
recoveredByOwner.set(candidate, recoveredCue);
|
||||
@@ -2055,6 +2078,111 @@ function recoverCanonicalAssEvents({
|
||||
);
|
||||
}
|
||||
|
||||
function bandFromNumpadAlignment(alignment: number): AssVerticalBand | null {
|
||||
if (alignment >= 7 && alignment <= 9) return 'top';
|
||||
if (alignment >= 4 && alignment <= 6) return 'middle';
|
||||
if (alignment >= 1 && alignment <= 3) return 'bottom';
|
||||
return null;
|
||||
}
|
||||
|
||||
// SSA v4 alignment reuses the legacy `\a` codes: 1-3 bottom, +4 top, +8 middle.
|
||||
function bandFromLegacyAlignment(alignment: number): AssVerticalBand | null {
|
||||
if (alignment >= 9 && alignment <= 11) return 'middle';
|
||||
if (alignment >= 5 && alignment <= 7) return 'top';
|
||||
if (alignment >= 1 && alignment <= 3) return 'bottom';
|
||||
return null;
|
||||
}
|
||||
|
||||
interface AssPlacementContext {
|
||||
playResY: number | null;
|
||||
/** Lowercased style name -> vertical band from the style's Alignment column. */
|
||||
styleBands: Map<string, AssVerticalBand>;
|
||||
}
|
||||
|
||||
const EMPTY_PLACEMENT_CONTEXT: AssPlacementContext = { playResY: null, styleBands: new Map() };
|
||||
|
||||
function parseAssPlacementContext(content: string): AssPlacementContext {
|
||||
const styleBands = new Map<string, AssVerticalBand>();
|
||||
let playResY: number | null = null;
|
||||
let section: 'info' | 'v4plus' | 'v4' | null = null;
|
||||
let alignmentIndex = -1;
|
||||
let nameIndex = -1;
|
||||
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
const sectionName = trimmed.toLowerCase();
|
||||
section =
|
||||
sectionName === '[script info]'
|
||||
? 'info'
|
||||
: sectionName === '[v4+ styles]'
|
||||
? 'v4plus'
|
||||
: sectionName === '[v4 styles]'
|
||||
? 'v4'
|
||||
: null;
|
||||
alignmentIndex = -1;
|
||||
nameIndex = -1;
|
||||
continue;
|
||||
}
|
||||
if (section === 'info') {
|
||||
const resMatch = trimmed.match(/^playresy\s*:\s*(\d+(?:\.\d+)?)\s*$/i);
|
||||
if (resMatch) playResY = Number(resMatch[1]);
|
||||
continue;
|
||||
}
|
||||
if (section !== 'v4plus' && section !== 'v4') continue;
|
||||
const separator = trimmed.indexOf(':');
|
||||
if (separator < 0) continue;
|
||||
const key = trimmed.slice(0, separator).trim().toLowerCase();
|
||||
const fields = trimmed.slice(separator + 1).split(',');
|
||||
if (key === 'format') {
|
||||
const names = fields.map((field) => field.trim().toLowerCase());
|
||||
alignmentIndex = names.indexOf('alignment');
|
||||
nameIndex = names.indexOf('name');
|
||||
continue;
|
||||
}
|
||||
if (key !== 'style' || alignmentIndex < 0 || nameIndex < 0) continue;
|
||||
const styleName = fields[nameIndex]?.trim().toLowerCase();
|
||||
const alignment = Number(fields[alignmentIndex]?.trim());
|
||||
if (!styleName || !Number.isFinite(alignment)) continue;
|
||||
const band =
|
||||
section === 'v4plus'
|
||||
? bandFromNumpadAlignment(alignment)
|
||||
: bandFromLegacyAlignment(alignment);
|
||||
if (band) styleBands.set(styleName, band);
|
||||
}
|
||||
|
||||
return { playResY, styleBands };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where on screen mpv will draw this event: an explicit `\pos`/`\move` coordinate when
|
||||
* the script declares its coordinate space, else an `\an`/`\a` override, else the
|
||||
* style's Alignment. Constant for the life of the event, which is what lets simultaneous
|
||||
* lines keep a stable stacking order in the overlay.
|
||||
*/
|
||||
function resolveVerticalBand(
|
||||
overrides: readonly AssOverrideCommand[],
|
||||
y: number | null,
|
||||
style: string,
|
||||
context: AssPlacementContext,
|
||||
): AssVerticalBand | undefined {
|
||||
if (y !== null && context.playResY && context.playResY > 0) {
|
||||
const ratio = y / context.playResY;
|
||||
return ratio < 1 / 3 ? 'top' : ratio < 2 / 3 ? 'middle' : 'bottom';
|
||||
}
|
||||
for (const command of overrides) {
|
||||
if (command.animated) continue;
|
||||
const name = command.name.toLowerCase();
|
||||
if (name !== 'an' && name !== 'a') continue;
|
||||
const band =
|
||||
name === 'an'
|
||||
? bandFromNumpadAlignment(Number(command.args))
|
||||
: bandFromLegacyAlignment(Number(command.args));
|
||||
if (band) return band;
|
||||
}
|
||||
return context.styleBands.get(style.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function parseAssCoordinate(value: string | undefined): number | null {
|
||||
if (!value?.trim()) return null;
|
||||
const coordinate = Number(value.trim());
|
||||
@@ -2064,29 +2192,157 @@ function parseAssCoordinate(value: string | undefined): number | null {
|
||||
function buildAssCueLayout(
|
||||
overrides: readonly AssOverrideCommand[],
|
||||
sourceOrder: number,
|
||||
style: string,
|
||||
placement: AssPlacementContext,
|
||||
): AssCueLayout {
|
||||
let x: number | null = null;
|
||||
let y: number | null = null;
|
||||
for (const command of overrides) {
|
||||
if (command.animated) continue;
|
||||
const name = command.name.toLowerCase();
|
||||
const args = command.args.split(',');
|
||||
if (name === 'pos') {
|
||||
x = parseAssCoordinate(args[0]) ?? x;
|
||||
y = parseAssCoordinate(args[1]) ?? y;
|
||||
continue;
|
||||
}
|
||||
if (name !== 'move') continue;
|
||||
const startX = parseAssCoordinate(args[0]);
|
||||
const startY = parseAssCoordinate(args[1]);
|
||||
const endX = parseAssCoordinate(args[2]);
|
||||
const endY = parseAssCoordinate(args[3]);
|
||||
if (startX !== null && endX !== null) {
|
||||
x = (startX + endX) / 2;
|
||||
}
|
||||
if (startY !== null && endY !== null) {
|
||||
y = (startY + endY) / 2;
|
||||
}
|
||||
}
|
||||
return y === null
|
||||
? { kind: 'source-order', sourceOrder }
|
||||
: { kind: 'positioned', sourceOrder, y };
|
||||
const verticalBand = resolveVerticalBand(overrides, y, style, placement);
|
||||
const base: AssCueLayout =
|
||||
y === null
|
||||
? { kind: 'source-order', sourceOrder }
|
||||
: { kind: 'positioned', sourceOrder, ...(x === null ? {} : { x }), y };
|
||||
return verticalBand ? { ...base, verticalBand } : base;
|
||||
}
|
||||
|
||||
function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
const ASS_FURIGANA_TEXT_PATTERN = /^[\p{Script=Hiragana}\p{Script=Katakana}ー・ \t\u3000]+$/u;
|
||||
const ASS_KANJI_PATTERN = /\p{Script=Han}/u;
|
||||
const MAX_ASS_FURIGANA_SCALE_PERCENT = 60;
|
||||
// The pixel geometry below is authored in the 540-line coordinate space Caption2Ass-style
|
||||
// broadcast CC converters emit, and is multiplied by PlayResY/540 so the same on-screen
|
||||
// window applies to scripts declaring other resolutions. Without a declaration the tuned
|
||||
// space is assumed.
|
||||
const ASS_FURIGANA_REFERENCE_PLAY_RES_Y = 540;
|
||||
const MIN_ASS_FURIGANA_BASE_GAP = 40;
|
||||
const MAX_ASS_FURIGANA_BASE_GAP = 68;
|
||||
const MIN_ASS_FURIGANA_HORIZONTAL_TOLERANCE = 80;
|
||||
const ASS_BASE_CHARACTER_WIDTH_ESTIMATE = 40;
|
||||
|
||||
function assFuriganaGeometryScale(playResY: number | null): number {
|
||||
return playResY && playResY > 0 ? playResY / ASS_FURIGANA_REFERENCE_PLAY_RES_Y : 1;
|
||||
}
|
||||
|
||||
function staticAssScalePercent(cue: AnnotatedSubtitleCue, axis: 'fscx' | 'fscy'): number | null {
|
||||
let scale: number | null = null;
|
||||
for (const command of cue.overrides) {
|
||||
if (command.animated || command.name.toLowerCase() !== axis) continue;
|
||||
const value = Number(command.args.trim());
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
scale = value;
|
||||
}
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
function isAssFuriganaCandidate(cue: AnnotatedSubtitleCue): boolean {
|
||||
const scaleX = staticAssScalePercent(cue, 'fscx');
|
||||
const scaleY = staticAssScalePercent(cue, 'fscy');
|
||||
return (
|
||||
cue.assLayout?.kind === 'positioned' &&
|
||||
ASS_FURIGANA_TEXT_PATTERN.test(cue.text) &&
|
||||
scaleX !== null &&
|
||||
scaleX <= MAX_ASS_FURIGANA_SCALE_PERCENT &&
|
||||
scaleY !== null &&
|
||||
scaleY <= MAX_ASS_FURIGANA_SCALE_PERCENT
|
||||
);
|
||||
}
|
||||
|
||||
function findAssFuriganaBase(
|
||||
furigana: AnnotatedSubtitleCue,
|
||||
cues: readonly AnnotatedSubtitleCue[],
|
||||
geometryScale: number,
|
||||
): AnnotatedSubtitleCue | null {
|
||||
if (furigana.assLayout?.kind !== 'positioned' || furigana.assLayout.x === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let nearest: { cue: AnnotatedSubtitleCue; gap: number } | null = null;
|
||||
for (const cue of cues) {
|
||||
if (
|
||||
cue === furigana ||
|
||||
cue.startTime !== furigana.startTime ||
|
||||
cue.endTime !== furigana.endTime ||
|
||||
cue.style !== furigana.style ||
|
||||
cue.layer !== furigana.layer ||
|
||||
cue.name !== furigana.name ||
|
||||
cue.assLayout?.kind !== 'positioned' ||
|
||||
!ASS_KANJI_PATTERN.test(cue.text)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const scaleY = staticAssScalePercent(cue, 'fscy');
|
||||
if (scaleY !== null && scaleY <= MAX_ASS_FURIGANA_SCALE_PERCENT) continue;
|
||||
|
||||
const gap = cue.assLayout.y - furigana.assLayout.y;
|
||||
if (
|
||||
gap < MIN_ASS_FURIGANA_BASE_GAP * geometryScale ||
|
||||
gap > MAX_ASS_FURIGANA_BASE_GAP * geometryScale
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (cue.assLayout.x === undefined) continue;
|
||||
const baseCharacterCount = [...cue.text.replace(/[ \t\u3000]/g, '')].length;
|
||||
const horizontalTolerance =
|
||||
Math.max(
|
||||
MIN_ASS_FURIGANA_HORIZONTAL_TOLERANCE,
|
||||
baseCharacterCount * ASS_BASE_CHARACTER_WIDTH_ESTIMATE,
|
||||
) * geometryScale;
|
||||
if (Math.abs(cue.assLayout.x - furigana.assLayout.x) > horizontalTolerance) continue;
|
||||
if (!nearest || gap < nearest.gap || (gap === nearest.gap && cue.order < nearest.cue.order)) {
|
||||
nearest = { cue, gap };
|
||||
}
|
||||
}
|
||||
return nearest?.cue ?? null;
|
||||
}
|
||||
|
||||
function removeAssFuriganaFromCueList(
|
||||
cues: AnnotatedSubtitleCue[],
|
||||
geometryScale: number,
|
||||
): AnnotatedSubtitleCue[] {
|
||||
const removed = new Set<AnnotatedSubtitleCue>();
|
||||
for (const cue of cues) {
|
||||
if (!isAssFuriganaCandidate(cue)) continue;
|
||||
const base = findAssFuriganaBase(cue, cues, geometryScale);
|
||||
if (!base) continue;
|
||||
base.assFurigana = [...new Set([...(base.assFurigana ?? []), cue.text])];
|
||||
removed.add(cue);
|
||||
}
|
||||
return removed.size === 0 ? cues : cues.filter((cue) => !removed.has(cue));
|
||||
}
|
||||
|
||||
function removeAssFuriganaEvents(
|
||||
events: ParsedAssEvents,
|
||||
playResY: number | null,
|
||||
): ParsedAssEvents {
|
||||
const geometryScale = assFuriganaGeometryScale(playResY);
|
||||
return {
|
||||
dialogue: removeAssFuriganaFromCueList(events.dialogue, geometryScale),
|
||||
comments: removeAssFuriganaFromCueList(events.comments, geometryScale),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAnnotatedAssEvents(content: string, placement: AssPlacementContext): ParsedAssEvents {
|
||||
const cues: AnnotatedSubtitleCue[] = [];
|
||||
const comments: AnnotatedSubtitleCue[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
@@ -2185,12 +2441,13 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
const effect = readField(fields, fieldIndex.effect);
|
||||
const layer = Number(readField(fields, fieldIndex.layer));
|
||||
const overrides = collectAssOverrideCommands(rawText);
|
||||
const style = readField(fields, fieldIndex.style);
|
||||
const cue: AnnotatedSubtitleCue = {
|
||||
startTime,
|
||||
endTime,
|
||||
text,
|
||||
rawText,
|
||||
style: readField(fields, fieldIndex.style),
|
||||
style,
|
||||
layer: Number.isFinite(layer) ? layer : 0,
|
||||
name: readField(fields, fieldIndex.name),
|
||||
effect,
|
||||
@@ -2198,7 +2455,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
overrides,
|
||||
overrideSignature: assOverrideSignature(overrides),
|
||||
order: eventOrder,
|
||||
assLayout: buildAssCueLayout(overrides, eventOrder),
|
||||
assLayout: buildAssCueLayout(overrides, eventOrder, style, placement),
|
||||
};
|
||||
eventOrder += 1;
|
||||
if (eventPrefix === ASS_COMMENT_PREFIX) {
|
||||
@@ -2212,7 +2469,13 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
}
|
||||
|
||||
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
const events = removeAssFontTextureEvents(parseAnnotatedAssEvents(content));
|
||||
const placement = content.includes('[')
|
||||
? parseAssPlacementContext(content)
|
||||
: EMPTY_PLACEMENT_CONTEXT;
|
||||
const events = removeAssFuriganaEvents(
|
||||
removeAssFontTextureEvents(parseAnnotatedAssEvents(content, placement)),
|
||||
placement.playResY,
|
||||
);
|
||||
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events));
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ export function createSubtitleProcessingController(
|
||||
try {
|
||||
const cachedTokenized = getCachedTokenization(text);
|
||||
if (cachedTokenized) {
|
||||
output = cachedTokenized;
|
||||
output = { ...cachedTokenized, text };
|
||||
} else {
|
||||
// Cache miss: show the plain line on time; the tokenized payload
|
||||
// upgrades it once ready. Skipped on refreshes of an already
|
||||
@@ -266,7 +266,7 @@ export function createSubtitleProcessingController(
|
||||
lastEmittedText = text;
|
||||
lastEmittedGeneration = cacheGeneration;
|
||||
lastPlainEmittedText = null;
|
||||
return cached;
|
||||
return { ...cached, text };
|
||||
},
|
||||
hasCachedSubtitle: (text: string) => {
|
||||
const cacheKey = normalizeSubtitleCacheKey(text);
|
||||
|
||||
@@ -84,6 +84,17 @@ function createDeferred<T>() {
|
||||
};
|
||||
}
|
||||
|
||||
test('tokenizeSubtitle keeps the blank line separating simultaneous cues', async () => {
|
||||
// The tokenized payload's text drives display; folding the cue boundary would merge
|
||||
// two speakers back onto one line the moment tokenization upgrades the plain emit.
|
||||
const result = await tokenizeSubtitle(
|
||||
'\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee',
|
||||
makeDeps({ getYomitanExt: () => null }),
|
||||
);
|
||||
|
||||
assert.equal(result.text, '\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee');
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle splits same-line grammar endings before applying annotations', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'猫です',
|
||||
@@ -1682,6 +1693,12 @@ test('tokenizeSubtitle normalizes newlines before Yomitan parse request', async
|
||||
assert.equal(result.tokens, null);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle preserves CRLF boundaries between simultaneous cues', async () => {
|
||||
const result = await tokenizeSubtitle('a\r\n\r\nb', makeDeps());
|
||||
|
||||
assert.deepEqual(result, { text: 'a\n\nb', tokens: null });
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle collapses zero-width separators before Yomitan parse request', async () => {
|
||||
let parseInput = '';
|
||||
const result = await tokenizeSubtitle(
|
||||
|
||||
@@ -887,7 +887,15 @@ export async function tokenizeSubtitle(
|
||||
text: string,
|
||||
deps: TokenizerServiceDeps,
|
||||
): Promise<SubtitleData> {
|
||||
const displayText = normalizePlainSubtitleText(text);
|
||||
// Normalize per cue group: the blank line separating simultaneous cues is display
|
||||
// structure the payload text must keep, or the tokenized upgrade re-merges lines the
|
||||
// provisional plain emit already showed apart.
|
||||
const displayText = text
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split(/\n{2,}/)
|
||||
.map((part) => normalizePlainSubtitleText(part))
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
// ASS decoding already happened upstream (cue parser for files, mpv for live text), so
|
||||
// all this drops is whitespace -- but a whitespace-only line still normalizes to empty.
|
||||
|
||||
@@ -39,6 +39,118 @@ test('convertYoutubeTimedTextToVtt does not swallow text after zero-length overl
|
||||
);
|
||||
});
|
||||
|
||||
test('convertYoutubeTimedTextToVtt extends rolling captions to the next window event', () => {
|
||||
// Real-world shape of YouTube's sentence-level auto captions: window-append
|
||||
// filler rows (a="1", sometimes without d) mark the display timeline, while
|
||||
// long text rows carry a placeholder d="3000" far shorter than the speech.
|
||||
const result = convertYoutubeTimedTextToVtt(
|
||||
[
|
||||
'<timedtext><body>',
|
||||
'<p t="98550" d="3010" w="1" a="1">\n</p>',
|
||||
'<p t="98560" d="3000" w="1"><s ac="0">ありがとうって言えないよね。こんなんじゃ。</s></p>',
|
||||
'<p t="106950" w="1" a="1">\n</p>',
|
||||
'<p t="106960" d="3799" w="1"><s ac="0">私だったら無理だよ。</s></p>',
|
||||
'</body></timedtext>',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result,
|
||||
[
|
||||
'WEBVTT',
|
||||
'',
|
||||
'00:01:38.560 --> 00:01:46.950',
|
||||
'ありがとうって言えないよね。こんなんじゃ。',
|
||||
'',
|
||||
'00:01:46.960 --> 00:01:50.759',
|
||||
'私だったら無理だよ。',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('convertYoutubeTimedTextToVtt pages oversized two-row rolling captions', () => {
|
||||
const text =
|
||||
'あの西に結構こう山田がスーパーアプローチしてるんだけど西気づかないからちょっとこっちも気づかない感じでこう接してあげようかなて思ってんだけどあの唇巻き込んじゃうしあの思ってることも全部縁に出ちゃって自分であちゃったって言っちゃうタイプなんで結構なんかこうドライなんだけどそこがおもろいよねみたいな';
|
||||
const result = convertYoutubeTimedTextToVtt(
|
||||
[
|
||||
'<timedtext format="3">',
|
||||
'<head>',
|
||||
'<ws id="1" mh="2" ju="0" sd="3"/>',
|
||||
'<wp id="1" ap="6" ah="20" av="100" rc="2" cc="40"/>',
|
||||
'</head>',
|
||||
'<body>',
|
||||
'<w t="0" id="1" wp="1" ws="1"/>',
|
||||
`<p t="60440" d="3000" w="1"><s ac="0">${text}</s></p>`,
|
||||
'<p t="72695" w="1" a="1">\n</p>',
|
||||
'</body>',
|
||||
'</timedtext>',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const cues = result
|
||||
.trim()
|
||||
.split(/\n\n/)
|
||||
.filter((block) => block.includes('-->'));
|
||||
const cueText = cues.map((cue) => cue.split('\n').slice(1).join('\n'));
|
||||
|
||||
assert.equal(cues.length, 2);
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.split('\n')[0]),
|
||||
['00:01:00.440 --> 00:01:07.064', '00:01:07.064 --> 00:01:12.695'],
|
||||
);
|
||||
assert.ok(cueText.every((page) => [...page].length <= 80));
|
||||
assert.equal(cueText.join(''), text);
|
||||
});
|
||||
|
||||
test('convertYoutubeTimedTextToVtt leaves pop-on captions intact', () => {
|
||||
const result = convertYoutubeTimedTextToVtt(
|
||||
[
|
||||
'<timedtext format="3">',
|
||||
'<head>',
|
||||
'<ws id="1" mh="0"/>',
|
||||
'<wp id="1" rc="2" cc="4"/>',
|
||||
'</head>',
|
||||
'<body>',
|
||||
'<w t="0" id="1" wp="1" ws="1"/>',
|
||||
'<p t="1000" d="3000" w="1">abcdefghijklmnopqrst</p>',
|
||||
'</body>',
|
||||
'</timedtext>',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result,
|
||||
['WEBVTT', '', '00:00:01.000 --> 00:00:04.000', 'abcdefghijklmnopqrst', ''].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('convertYoutubeTimedTextToVtt keeps explicit 3000ms sound-cue durations in rolling documents', () => {
|
||||
const result = convertYoutubeTimedTextToVtt(
|
||||
[
|
||||
'<timedtext><body>',
|
||||
'<p t="20305" d="3000" w="1">[音楽]</p>',
|
||||
'<p t="26269" w="1" a="1">\n</p>',
|
||||
'<p t="26279" d="3000" w="1"><s ac="0">じゃあ、君からお願いします。</s></p>',
|
||||
'</body></timedtext>',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result,
|
||||
[
|
||||
'WEBVTT',
|
||||
'',
|
||||
'00:00:20.305 --> 00:00:23.305',
|
||||
'[音楽]',
|
||||
'',
|
||||
'00:00:26.279 --> 00:00:29.279',
|
||||
'じゃあ、君からお願いします。',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeYoutubeAutoVtt strips cumulative rolling-caption prefixes', () => {
|
||||
const result = normalizeYoutubeAutoVtt(
|
||||
[
|
||||
|
||||
@@ -2,9 +2,31 @@ interface YoutubeTimedTextRow {
|
||||
startMs: number;
|
||||
durationMs: number;
|
||||
text: string;
|
||||
isGenerated: boolean;
|
||||
rollingWindow: YoutubeRollingWindow | null;
|
||||
}
|
||||
|
||||
interface YoutubeRollingWindow {
|
||||
rowCount: number;
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
interface YoutubeTimedTextWindowDefinitions {
|
||||
rollingStyleIds: Set<string>;
|
||||
positions: Map<string, YoutubeRollingWindow>;
|
||||
windows: Map<string, YoutubeRollingWindow>;
|
||||
}
|
||||
|
||||
interface YoutubeTimedTextDocument {
|
||||
rows: YoutubeTimedTextRow[];
|
||||
// Start times of every <p> event, including empty window-append fillers.
|
||||
// Rolling speech rows with a 3000ms placeholder display until the next event.
|
||||
eventStartsMs: number[];
|
||||
hasRollingWindowEvents: boolean;
|
||||
}
|
||||
|
||||
const YOUTUBE_TIMEDTEXT_EXTENSIONS = new Set(['srv1', 'srv2', 'srv3', 'ytsrv3']);
|
||||
const YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS = 3_000;
|
||||
|
||||
function decodeNumericEntity(match: string, codePoint: number): string {
|
||||
if (
|
||||
@@ -39,27 +61,129 @@ function parseAttributeMap(raw: string): Map<string, string> {
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] {
|
||||
function parsePositiveInteger(value: string | undefined): number | null {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function extractYoutubeTimedTextWindowDefinitions(xml: string): YoutubeTimedTextWindowDefinitions {
|
||||
const rollingStyleIds = new Set<string>();
|
||||
for (const match of xml.matchAll(/<ws\b([^>]*)\/?\s*>/g)) {
|
||||
const attrs = parseAttributeMap(match[1] ?? '');
|
||||
const id = attrs.get('id');
|
||||
if (id !== undefined && attrs.get('mh') === '2') {
|
||||
rollingStyleIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const positions = new Map<string, YoutubeRollingWindow>();
|
||||
for (const match of xml.matchAll(/<wp\b([^>]*)\/?\s*>/g)) {
|
||||
const attrs = parseAttributeMap(match[1] ?? '');
|
||||
const id = attrs.get('id');
|
||||
const rowCount = parsePositiveInteger(attrs.get('rc'));
|
||||
const columnCount = parsePositiveInteger(attrs.get('cc'));
|
||||
if (id !== undefined && rowCount !== null && columnCount !== null) {
|
||||
positions.set(id, { rowCount, columnCount });
|
||||
}
|
||||
}
|
||||
|
||||
const windows = new Map<string, YoutubeRollingWindow>();
|
||||
for (const match of xml.matchAll(/<w\b([^>]*)\/?\s*>/g)) {
|
||||
const attrs = parseAttributeMap(match[1] ?? '');
|
||||
const id = attrs.get('id');
|
||||
const styleId = attrs.get('ws');
|
||||
const positionId = attrs.get('wp');
|
||||
const position = positionId === undefined ? undefined : positions.get(positionId);
|
||||
if (
|
||||
id !== undefined &&
|
||||
styleId !== undefined &&
|
||||
rollingStyleIds.has(styleId) &&
|
||||
position !== undefined
|
||||
) {
|
||||
windows.set(id, position);
|
||||
}
|
||||
}
|
||||
|
||||
return { rollingStyleIds, positions, windows };
|
||||
}
|
||||
|
||||
function resolveRollingWindow(
|
||||
attrs: Map<string, string>,
|
||||
definitions: YoutubeTimedTextWindowDefinitions,
|
||||
): YoutubeRollingWindow | null {
|
||||
const windowId = attrs.get('w');
|
||||
if (windowId !== undefined) {
|
||||
return definitions.windows.get(windowId) ?? null;
|
||||
}
|
||||
|
||||
const styleId = attrs.get('ws');
|
||||
const positionId = attrs.get('wp');
|
||||
if (
|
||||
styleId === undefined ||
|
||||
positionId === undefined ||
|
||||
!definitions.rollingStyleIds.has(styleId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return definitions.positions.get(positionId) ?? null;
|
||||
}
|
||||
|
||||
function extractYoutubeTimedTextDocument(xml: string): YoutubeTimedTextDocument {
|
||||
const rows: YoutubeTimedTextRow[] = [];
|
||||
const eventStartsMs: number[] = [];
|
||||
let hasRollingWindowEvents = false;
|
||||
const windowDefinitions = extractYoutubeTimedTextWindowDefinitions(xml);
|
||||
|
||||
for (const match of xml.matchAll(/<p\b([^>]*)>([\s\S]*?)<\/p>/g)) {
|
||||
const attrs = parseAttributeMap(match[1] ?? '');
|
||||
const startMs = Number(attrs.get('t'));
|
||||
if (!Number.isFinite(startMs)) {
|
||||
continue;
|
||||
}
|
||||
eventStartsMs.push(startMs);
|
||||
if (attrs.get('a') === '1') {
|
||||
hasRollingWindowEvents = true;
|
||||
}
|
||||
|
||||
const durationMs = Number(attrs.get('d'));
|
||||
if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) {
|
||||
if (!Number.isFinite(durationMs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const inner = (match[2] ?? '').replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '');
|
||||
const rawInner = match[2] ?? '';
|
||||
const inner = rawInner.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '');
|
||||
const text = decodeHtmlEntities(inner).trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({ startMs, durationMs, text });
|
||||
rows.push({
|
||||
startMs,
|
||||
durationMs,
|
||||
text,
|
||||
isGenerated: /<s\b/.test(rawInner),
|
||||
rollingWindow: resolveRollingWindow(attrs, windowDefinitions),
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
eventStartsMs.sort((a, b) => a - b);
|
||||
return { rows, eventStartsMs, hasRollingWindowEvents };
|
||||
}
|
||||
|
||||
function findNextEventStartMs(eventStartsMs: number[], afterMs: number): number | undefined {
|
||||
for (const startMs of eventStartsMs) {
|
||||
if (startMs > afterMs) {
|
||||
return startMs;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isGeneratedRollingCue(row: YoutubeTimedTextRow, hasRollingWindowEvents: boolean): boolean {
|
||||
return row.isGenerated && (row.rollingWindow !== null || hasRollingWindowEvents);
|
||||
}
|
||||
|
||||
function formatVttTimestamp(ms: number): string {
|
||||
@@ -71,6 +195,79 @@ function formatVttTimestamp(ms: number): string {
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
const ROLLING_PAGE_BREAK_PATTERN = /[\s、。!?!?]/u;
|
||||
|
||||
// VTT cannot carry SRV3's row and column limits. Page only roll-up windows so
|
||||
// the overlay keeps their bounded presentation without changing authored cues.
|
||||
function splitRollingCaptionIntoPages(text: string, rollingWindow: YoutubeRollingWindow): string[] {
|
||||
const pageCapacity = rollingWindow.rowCount * rollingWindow.columnCount;
|
||||
const characters = [...text];
|
||||
if (
|
||||
!Number.isSafeInteger(pageCapacity) ||
|
||||
pageCapacity <= 0 ||
|
||||
characters.length <= pageCapacity
|
||||
) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const pages: string[] = [];
|
||||
let pageStart = 0;
|
||||
while (pageStart < characters.length) {
|
||||
let pageEnd = Math.min(pageStart + pageCapacity, characters.length);
|
||||
if (pageEnd < characters.length) {
|
||||
const earliestNaturalBreak = pageStart + Math.ceil(pageCapacity * 0.6);
|
||||
for (let index = pageEnd - 1; index >= earliestNaturalBreak; index -= 1) {
|
||||
if (ROLLING_PAGE_BREAK_PATTERN.test(characters[index]!)) {
|
||||
pageEnd = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
pages.push(characters.slice(pageStart, pageEnd).join(''));
|
||||
pageStart = pageEnd;
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
interface TimedCaptionPage {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function timeCaptionPages(input: {
|
||||
text: string;
|
||||
pages: string[];
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}): TimedCaptionPage[] {
|
||||
const durationMs = input.endMs - input.startMs;
|
||||
if (input.pages.length === 1 || durationMs < input.pages.length) {
|
||||
return [{ startMs: input.startMs, endMs: input.endMs, text: input.text }];
|
||||
}
|
||||
|
||||
const totalCharacters = [...input.text].length;
|
||||
const timedPages: TimedCaptionPage[] = [];
|
||||
let consumedCharacters = 0;
|
||||
let pageStartMs = input.startMs;
|
||||
// Automatic captions often omit span offsets, so distribute the known cue
|
||||
// duration by page length while guaranteeing every page at least one ms.
|
||||
for (let index = 0; index < input.pages.length; index += 1) {
|
||||
const page = input.pages[index]!;
|
||||
consumedCharacters += [...page].length;
|
||||
const remainingPages = input.pages.length - index - 1;
|
||||
const proportionalEndMs =
|
||||
input.startMs + Math.round((durationMs * consumedCharacters) / totalCharacters);
|
||||
const pageEndMs =
|
||||
remainingPages === 0
|
||||
? input.endMs
|
||||
: Math.min(Math.max(proportionalEndMs, pageStartMs + 1), input.endMs - remainingPages);
|
||||
timedPages.push({ startMs: pageStartMs, endMs: pageEndMs, text: page });
|
||||
pageStartMs = pageEndMs;
|
||||
}
|
||||
return timedPages;
|
||||
}
|
||||
|
||||
export function isYoutubeTimedTextExtension(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
@@ -79,7 +276,7 @@ export function isYoutubeTimedTextExtension(value: string | undefined): boolean
|
||||
}
|
||||
|
||||
export function convertYoutubeTimedTextToVtt(xml: string): string {
|
||||
const rows = extractYoutubeTimedTextRows(xml);
|
||||
const { rows, eventStartsMs, hasRollingWindowEvents } = extractYoutubeTimedTextDocument(xml);
|
||||
if (rows.length === 0) {
|
||||
return 'WEBVTT\n';
|
||||
}
|
||||
@@ -90,10 +287,19 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
|
||||
const row = rows[index]!;
|
||||
const nextRow = rows[index + 1];
|
||||
const unclampedEnd = row.startMs + row.durationMs;
|
||||
// YouTube uses exactly 3000ms as a placeholder for generated rolling speech.
|
||||
// Plain-text cues can explicitly use the same duration and must keep it.
|
||||
const nextEventStart =
|
||||
isGeneratedRollingCue(row, hasRollingWindowEvents) &&
|
||||
row.durationMs === YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS
|
||||
? findNextEventStartMs(eventStartsMs, row.startMs)
|
||||
: undefined;
|
||||
const clampedEnd =
|
||||
nextRow && unclampedEnd > nextRow.startMs
|
||||
? Math.max(row.startMs, nextRow.startMs - 1)
|
||||
: unclampedEnd;
|
||||
nextEventStart !== undefined
|
||||
? nextEventStart
|
||||
: nextRow && unclampedEnd > nextRow.startMs
|
||||
? Math.max(row.startMs, nextRow.startMs - 1)
|
||||
: unclampedEnd;
|
||||
if (clampedEnd <= row.startMs) {
|
||||
continue;
|
||||
}
|
||||
@@ -106,9 +312,19 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
blocks.push(
|
||||
`${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${text}`,
|
||||
);
|
||||
const pages = row.rollingWindow
|
||||
? splitRollingCaptionIntoPages(text, row.rollingWindow)
|
||||
: [text];
|
||||
for (const page of timeCaptionPages({
|
||||
text,
|
||||
pages,
|
||||
startMs: row.startMs,
|
||||
endMs: clampedEnd,
|
||||
})) {
|
||||
blocks.push(
|
||||
`${formatVttTimestamp(page.startMs)} --> ${formatVttTimestamp(page.endMs)}\n${page.text}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return `WEBVTT\n\n${blocks.join('\n\n')}\n`;
|
||||
|
||||
+43
@@ -465,6 +465,13 @@ import { createMainBootServices, type MainBootServicesResult } from './main/boot
|
||||
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
||||
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
||||
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
|
||||
import { MediaTimingPreviewSession } from './core/services/media-timing-preview';
|
||||
import { generateSpeechWaveform } from './core/services/media-timing-waveform';
|
||||
import {
|
||||
collectMediaTimingContextLines,
|
||||
createMediaTimingReviewRuntime,
|
||||
} from './main/runtime/media-timing-review';
|
||||
import { openMediaTimingReviewModal } from './main/runtime/media-timing-review-open';
|
||||
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
||||
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
|
||||
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
|
||||
@@ -2885,6 +2892,26 @@ 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),
|
||||
getSubtitleContextLines: (range) =>
|
||||
collectMediaTimingContextLines({
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [],
|
||||
startTime: range.startTime,
|
||||
endTime: range.endTime,
|
||||
}),
|
||||
openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload),
|
||||
showStatus: (message) =>
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }),
|
||||
});
|
||||
|
||||
function openOverlayHostedModalWithOsd(
|
||||
openModal: (deps: ReturnType<typeof createOverlayHostedModalOpenDeps>) => Promise<boolean>,
|
||||
unavailableMessage: string,
|
||||
@@ -5121,6 +5148,7 @@ function initializeOverlayRuntime(): void {
|
||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations);
|
||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
||||
appState.ankiIntegration?.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview);
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
@@ -5530,6 +5558,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||
},
|
||||
mainDeps: {
|
||||
previewMediaTimingReview: (request) => mediaTimingReviewRuntime.previewRange(request),
|
||||
getMediaTimingReviewWaveform: (request) => mediaTimingReviewRuntime.getWaveform(request),
|
||||
stopMediaTimingReviewPreview: (reviewId) => mediaTimingReviewRuntime.stopPreview(reviewId),
|
||||
resolveMediaTimingReview: (request) => mediaTimingReviewRuntime.resolveReview(request),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(),
|
||||
focusMainWindow: () => {
|
||||
@@ -5563,6 +5595,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
}
|
||||
},
|
||||
onOverlayModalClosed: (modal, senderWindow) => {
|
||||
if (modal === 'media-timing-review') {
|
||||
void mediaTimingReviewRuntime.dispose();
|
||||
}
|
||||
if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) {
|
||||
subtitleSidebarRequestedOpen = false;
|
||||
}
|
||||
@@ -5916,6 +5951,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
|
||||
consumePendingSubtitleMiningContext,
|
||||
);
|
||||
appState.ankiIntegration?.setMediaTimingReviewCallback(
|
||||
mediaTimingReviewRuntime.requestReview,
|
||||
);
|
||||
},
|
||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
getCachedMediaPath: (currentVideoPath, kind) =>
|
||||
@@ -5925,6 +5963,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
showDesktopNotification,
|
||||
showOverlayNotification: (payload) =>
|
||||
overlayNotificationsRuntime.showOverlayNotification(payload),
|
||||
dismissOverlayNotification: (id) =>
|
||||
overlayNotificationsRuntime.dismissOverlayNotification(id),
|
||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||
broadcastRuntimeOptionsChanged: () =>
|
||||
overlayVisibilityComposer.broadcastRuntimeOptionsChanged(),
|
||||
@@ -6238,6 +6278,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
||||
if (overlayManager.getModalWindow() !== window) {
|
||||
return;
|
||||
}
|
||||
void mediaTimingReviewRuntime.dispose();
|
||||
overlayManager.setModalWindow(null);
|
||||
}
|
||||
},
|
||||
@@ -6415,6 +6456,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
showDesktopNotification,
|
||||
showOverlayNotification: (payload) =>
|
||||
overlayNotificationsRuntime.showOverlayNotification(payload),
|
||||
dismissOverlayNotification: (id) =>
|
||||
overlayNotificationsRuntime.dismissOverlayNotification(id),
|
||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
getCachedMediaPath: (currentVideoPath, kind) =>
|
||||
|
||||
@@ -62,6 +62,10 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint'];
|
||||
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
|
||||
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
||||
previewMediaTimingReview?: IpcDepsRuntimeOptions['previewMediaTimingReview'];
|
||||
getMediaTimingReviewWaveform?: IpcDepsRuntimeOptions['getMediaTimingReviewWaveform'];
|
||||
stopMediaTimingReviewPreview?: IpcDepsRuntimeOptions['stopMediaTimingReviewPreview'];
|
||||
resolveMediaTimingReview?: IpcDepsRuntimeOptions['resolveMediaTimingReview'];
|
||||
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
||||
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
||||
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
|
||||
@@ -132,6 +136,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
|
||||
getYoutubeMediaSourceUrl?: AnkiJimakuIpcRuntimeOptions['getYoutubeMediaSourceUrl'];
|
||||
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
dismissOverlayNotification?: (id: string) => void;
|
||||
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
|
||||
broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged'];
|
||||
getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver'];
|
||||
@@ -256,6 +261,10 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
onOverlayInteractiveHint: params.onOverlayInteractiveHint,
|
||||
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
|
||||
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
||||
previewMediaTimingReview: params.previewMediaTimingReview,
|
||||
getMediaTimingReviewWaveform: params.getMediaTimingReviewWaveform,
|
||||
stopMediaTimingReviewPreview: params.stopMediaTimingReviewPreview,
|
||||
resolveMediaTimingReview: params.resolveMediaTimingReview,
|
||||
openYomitanSettings: params.openYomitanSettings,
|
||||
quitApp: params.quitApp,
|
||||
toggleVisibleOverlay: params.toggleVisibleOverlay,
|
||||
@@ -334,6 +343,7 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
|
||||
: {}),
|
||||
showDesktopNotification: params.showDesktopNotification,
|
||||
showOverlayNotification: params.showOverlayNotification,
|
||||
dismissOverlayNotification: params.dismissOverlayNotification,
|
||||
createFieldGroupingCallback: params.createFieldGroupingCallback,
|
||||
broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged,
|
||||
getFieldGroupingResolver: params.getFieldGroupingResolver,
|
||||
|
||||
@@ -156,6 +156,7 @@ test('createConfigHotReloadAppliedHandler applies only changed Anki media option
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
config.ankiConnect.media.normalizeAudio = false;
|
||||
config.ankiConnect.media.mirrorMpvVolume = false;
|
||||
config.ankiConnect.media.reviewTiming = true;
|
||||
const ankiPatches: unknown[] = [];
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
@@ -181,10 +182,18 @@ test('createConfigHotReloadAppliedHandler applies only changed Anki media option
|
||||
},
|
||||
config,
|
||||
);
|
||||
applyHotReload(
|
||||
{
|
||||
hotReloadFields: ['ankiConnect.media.reviewTiming'],
|
||||
restartRequiredFields: [],
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
assert.deepEqual(ankiPatches, [
|
||||
{ media: { normalizeAudio: false } },
|
||||
{ media: { mirrorMpvVolume: false } },
|
||||
{ media: { reviewTiming: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -100,6 +100,9 @@ function buildAnkiRuntimeConfigPatch(
|
||||
if (diff.hotReloadFields.includes('ankiConnect.media.mirrorMpvVolume')) {
|
||||
mediaPatch.mirrorMpvVolume = config.ankiConnect.media.mirrorMpvVolume;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.media.reviewTiming')) {
|
||||
mediaPatch.reviewTiming = config.ankiConnect.media.reviewTiming;
|
||||
}
|
||||
if (Object.keys(mediaPatch).length > 0) {
|
||||
patch.media = mediaPatch;
|
||||
}
|
||||
|
||||
@@ -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,451 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
|
||||
import {
|
||||
buildMediaTimingReviewPayload,
|
||||
collectMediaTimingContextLines,
|
||||
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: 'confirm', startTime: 10, endTime: 12, text: ' ' },
|
||||
}),
|
||||
{ ok: false, message: 'The combined sentence text is invalid.' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'discard' } }),
|
||||
{ ok: true },
|
||||
);
|
||||
assert.deepEqual(await pendingDecision, { action: 'discard' });
|
||||
assert.deepEqual(previewCalls, []);
|
||||
});
|
||||
|
||||
test('collectMediaTimingContextLines splits cues around the mined range', () => {
|
||||
const cues = [
|
||||
{ text: '一行目', startTime: 0, endTime: 2 },
|
||||
{ text: '二行目', startTime: 2.5, endTime: 4 },
|
||||
{ text: '', startTime: 4.2, endTime: 4.4 },
|
||||
{ text: '採掘行', startTime: 5, endTime: 7 },
|
||||
{ text: '四行目', startTime: 7.5, endTime: 9 },
|
||||
{ text: '五行目', startTime: 9.5, endTime: 11 },
|
||||
];
|
||||
|
||||
const context = collectMediaTimingContextLines({ cues, startTime: 5, endTime: 7 });
|
||||
|
||||
assert.deepEqual(context.previous, [
|
||||
{ text: '一行目', startTime: 0, endTime: 2 },
|
||||
{ text: '二行目', startTime: 2.5, endTime: 4 },
|
||||
]);
|
||||
assert.deepEqual(context.next, [
|
||||
{ text: '四行目', startTime: 7.5, endTime: 9 },
|
||||
{ text: '五行目', startTime: 9.5, endTime: 11 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('collectMediaTimingContextLines falls back to played history when no cues are loaded', () => {
|
||||
const context = collectMediaTimingContextLines({
|
||||
cues: [],
|
||||
fallbackPrevious: [
|
||||
{ displayText: '前の行', startTime: 1, endTime: 2 },
|
||||
{ displayText: '採掘行', startTime: 5, endTime: 7 },
|
||||
],
|
||||
startTime: 5,
|
||||
endTime: 7,
|
||||
});
|
||||
|
||||
assert.deepEqual(context.previous, [{ text: '前の行', startTime: 1, endTime: 2 }]);
|
||||
assert.deepEqual(context.next, []);
|
||||
});
|
||||
|
||||
test('media timing review watchdog falls back when the renderer stops responding', async () => {
|
||||
const { pendingDecision } = await startActiveMediaTimingReview({ decisionTimeoutMs: 0 });
|
||||
|
||||
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,426 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import type {
|
||||
MediaTimingReviewActionResult,
|
||||
MediaTimingReviewContextLine,
|
||||
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;
|
||||
const CONTEXT_LINE_LIMIT = 12;
|
||||
const CONTEXT_LINE_EPSILON_SECONDS = 0.05;
|
||||
|
||||
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[]>;
|
||||
getSubtitleContextLines?: (range: { startTime: number; endTime: number }) => {
|
||||
previous: MediaTimingReviewContextLine[];
|
||||
next: MediaTimingReviewContextLine[];
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the subtitle lines adjacent to the mined range that the review modal can pull
|
||||
* onto the card. Parsed cues cover both directions; when none are loaded (e.g. the
|
||||
* active track was never parsed) the timing tracker's history still provides the
|
||||
* lines that already played, so only "next" is unavailable.
|
||||
*/
|
||||
export function collectMediaTimingContextLines(options: {
|
||||
cues: readonly { text: string; startTime: number; endTime: number }[];
|
||||
fallbackPrevious?: readonly { displayText: string; startTime: number; endTime: number }[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}): { previous: MediaTimingReviewContextLine[]; next: MediaTimingReviewContextLine[] } {
|
||||
const usable = options.cues
|
||||
.filter(
|
||||
(cue) =>
|
||||
cue.text.trim().length > 0 &&
|
||||
Number.isFinite(cue.startTime) &&
|
||||
Number.isFinite(cue.endTime) &&
|
||||
cue.endTime > cue.startTime,
|
||||
)
|
||||
.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime);
|
||||
|
||||
let previous = usable
|
||||
.filter((cue) => cue.endTime <= options.startTime + CONTEXT_LINE_EPSILON_SECONDS)
|
||||
.slice(-CONTEXT_LINE_LIMIT)
|
||||
.map(({ text, startTime, endTime }) => ({ text: text.trim(), startTime, endTime }));
|
||||
const next = usable
|
||||
.filter((cue) => cue.startTime >= options.endTime - CONTEXT_LINE_EPSILON_SECONDS)
|
||||
.slice(0, CONTEXT_LINE_LIMIT)
|
||||
.map(({ text, startTime, endTime }) => ({ text: text.trim(), startTime, endTime }));
|
||||
|
||||
if (previous.length === 0 && options.fallbackPrevious) {
|
||||
previous = options.fallbackPrevious
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.displayText.trim().length > 0 &&
|
||||
Number.isFinite(entry.startTime) &&
|
||||
Number.isFinite(entry.endTime) &&
|
||||
entry.endTime > entry.startTime &&
|
||||
entry.endTime <= options.startTime + CONTEXT_LINE_EPSILON_SECONDS,
|
||||
)
|
||||
.slice(-CONTEXT_LINE_LIMIT)
|
||||
.map((entry) => ({
|
||||
text: entry.displayText.trim(),
|
||||
startTime: entry.startTime,
|
||||
endTime: entry.endTime,
|
||||
}));
|
||||
}
|
||||
return { previous, next };
|
||||
}
|
||||
|
||||
function isValidMediaTimingRange(
|
||||
payload: MediaTimingReviewOpenPayload,
|
||||
startTime: number,
|
||||
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;
|
||||
contextLines?: {
|
||||
previous: MediaTimingReviewContextLine[];
|
||||
next: MediaTimingReviewContextLine[];
|
||||
};
|
||||
},
|
||||
): 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,
|
||||
previousLines: options.contextLines?.previous ?? [],
|
||||
nextLines: options.contextLines?.next ?? [],
|
||||
...(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;
|
||||
|
||||
let contextLines: ReturnType<NonNullable<typeof deps.getSubtitleContextLines>> | undefined;
|
||||
try {
|
||||
contextLines = deps.getSubtitleContextLines?.({
|
||||
startTime: request.startTime,
|
||||
endTime: request.endTime,
|
||||
});
|
||||
} catch {
|
||||
contextLines = undefined;
|
||||
}
|
||||
const payload = buildMediaTimingReviewPayload(request, {
|
||||
reviewId: randomUUID(),
|
||||
mediaDuration: finiteNumber(durationRaw) ?? undefined,
|
||||
...(contextLines ? { contextLines } : {}),
|
||||
});
|
||||
const previewSession = deps.createPreviewSession();
|
||||
const preview = previewSession
|
||||
.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, text } = request.decision;
|
||||
if (!isValidMediaTimingRange(current.payload, startTime, endTime)) {
|
||||
return { ok: false, message: 'The selected timing range is invalid.' };
|
||||
}
|
||||
if (text !== undefined && (typeof text !== 'string' || text.trim().length === 0)) {
|
||||
return { ok: false, message: 'The combined sentence text is invalid.' };
|
||||
}
|
||||
}
|
||||
current.resolve(request.decision);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,7 @@ type InitializeOverlayRuntimeCore = (options: {
|
||||
} | null;
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
dismissOverlayNotification?: (id: string) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
|
||||
@@ -33,6 +33,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
||||
getOverlayWindows: () => [],
|
||||
getResolvedConfig: () => ({}),
|
||||
showDesktopNotification: () => calls.push('notify'),
|
||||
showOverlayNotification: () => calls.push('show-overlay'),
|
||||
dismissOverlayNotification: () => calls.push('dismiss-overlay'),
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
@@ -57,6 +59,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
||||
deps.refreshCurrentSubtitle?.();
|
||||
deps.syncOverlayShortcuts();
|
||||
deps.showDesktopNotification('title', {});
|
||||
deps.showOverlayNotification?.({ title: 'title' });
|
||||
deps.dismissOverlayNotification?.('notification-id');
|
||||
|
||||
const tracker = {
|
||||
close: () => {},
|
||||
@@ -73,6 +77,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
||||
'refresh-subtitle',
|
||||
'sync-shortcuts',
|
||||
'notify',
|
||||
'show-overlay',
|
||||
'dismiss-overlay',
|
||||
]);
|
||||
assert.equal(appState.windowTracker, tracker);
|
||||
assert.deepEqual(appState.ankiIntegration, { id: 'anki' });
|
||||
|
||||
@@ -39,6 +39,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
dismissOverlayNotification?: (id: string) => void;
|
||||
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath'];
|
||||
@@ -78,6 +79,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
},
|
||||
showDesktopNotification: deps.showDesktopNotification,
|
||||
showOverlayNotification: deps.showOverlayNotification,
|
||||
dismissOverlayNotification: deps.dismissOverlayNotification,
|
||||
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
||||
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
||||
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
|
||||
|
||||
@@ -22,6 +22,8 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
||||
getRuntimeOptionsManager: () => null,
|
||||
setAnkiIntegration: () => calls.push('set-anki'),
|
||||
showDesktopNotification: () => calls.push('notify'),
|
||||
showOverlayNotification: () => calls.push('show-overlay'),
|
||||
dismissOverlayNotification: () => calls.push('dismiss-overlay'),
|
||||
createFieldGroupingCallback: () => async () => ({
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
@@ -47,6 +49,8 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
||||
options.setWindowTracker(null);
|
||||
options.setAnkiIntegration(null);
|
||||
options.showDesktopNotification('title', {});
|
||||
options.showOverlayNotification?.({ title: 'title' });
|
||||
options.dismissOverlayNotification?.('notification-id');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'create-main',
|
||||
@@ -58,5 +62,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
||||
'set-tracker',
|
||||
'set-anki',
|
||||
'notify',
|
||||
'show-overlay',
|
||||
'dismiss-overlay',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ type OverlayRuntimeOptions = {
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
dismissOverlayNotification?: (id: string) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
@@ -73,6 +74,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
dismissOverlayNotification?: (id: string) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
@@ -107,6 +109,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
setAnkiIntegration: deps.setAnkiIntegration,
|
||||
showDesktopNotification: deps.showDesktopNotification,
|
||||
showOverlayNotification: deps.showOverlayNotification,
|
||||
dismissOverlayNotification: deps.dismissOverlayNotification,
|
||||
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
||||
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
||||
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
|
||||
|
||||
@@ -60,7 +60,33 @@ test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () =
|
||||
{ startTime: 1, endTime: 3, text: '二行目' },
|
||||
],
|
||||
}),
|
||||
'一行目\n二行目',
|
||||
'一行目\n\n二行目',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText accounts for live ASS furigana after canonical recovery', () => {
|
||||
const ass = [
|
||||
'[Script Info]',
|
||||
'PlayResY: 540',
|
||||
'',
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(192,77)}ごめん 結局 ぬれたな。',
|
||||
'Comment: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,大丈夫。',
|
||||
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,113)\\fscx50\\fscy50}だいじょうぶ',
|
||||
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)\\clip(m 1 1)}大丈夫。',
|
||||
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)\\clip(m 2 2)}大丈夫。',
|
||||
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)\\clip(m 3 3)}大丈夫。',
|
||||
].join('\n');
|
||||
const cues = parseSubtitleCues(ass, 'polar-opposites-s02e08.ass');
|
||||
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: 'ごめん 結局 ぬれたな。\nだいじょうぶ\n大丈夫。',
|
||||
currentTimeSec: 159,
|
||||
cues,
|
||||
}),
|
||||
'ごめん 結局 ぬれたな。\n\n大丈夫。',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -195,7 +221,7 @@ test('resolvePrimarySubtitleText combines parsed dialogue with a reconstructed l
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(text, '普通のセリフ\n今 手にある');
|
||||
assert.equal(text, '普通のセリフ\n\n今 手にある');
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText uses fragment grids only to account for live sign pieces', () => {
|
||||
@@ -300,7 +326,32 @@ test('resolvePrimarySubtitleText combines simultaneous canonical cues in source
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(text, 'first\nsecond');
|
||||
assert.equal(text, 'first\n\nsecond');
|
||||
});
|
||||
|
||||
test('resolveCanonicalPrimarySubtitle orders active cues from top to bottom', () => {
|
||||
const resolved = resolveCanonicalPrimarySubtitle({
|
||||
liveText: 'bottom\ntop',
|
||||
currentTimeSec: 2,
|
||||
cues: [
|
||||
{
|
||||
startTime: 1,
|
||||
endTime: 3,
|
||||
text: 'bottom',
|
||||
source: 'canonical-ass',
|
||||
assLayout: { kind: 'source-order', sourceOrder: 1, verticalBand: 'bottom' },
|
||||
},
|
||||
{
|
||||
startTime: 1,
|
||||
endTime: 3,
|
||||
text: 'top',
|
||||
source: 'canonical-ass',
|
||||
assLayout: { kind: 'source-order', sourceOrder: 0, verticalBand: 'top' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(resolved?.text, 'top\n\nbottom');
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText collapses whitespace variants of a canonical lyric', () => {
|
||||
@@ -498,6 +549,23 @@ test('stripCanonicalFragmentLines drops a live glyph wall with no nearby canonic
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText keeps a line joining an active cue despite stale time-pos', () => {
|
||||
// Issue #220: mpv publishes the combined sub-text the moment a joining line's first
|
||||
// frame renders, while the observed time-pos still sits just before that line's
|
||||
// start. The joining cue must not be filtered out as inactive.
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: 'Балда! Балда, балда, балда!\nСестренка не может остановиться',
|
||||
currentTimeSec: 767.78,
|
||||
cues: [
|
||||
{ startTime: 767.19, endTime: 772.78, text: 'Балда! Балда, балда, балда!' },
|
||||
{ startTime: 767.79, endTime: 771.15, text: 'Сестренка не может остановиться' },
|
||||
],
|
||||
}),
|
||||
'Балда! Балда, балда, балда!\n\nСестренка не может остановиться',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText drops a finished lyric whose exit ghosts outlive it beside a raw line', () => {
|
||||
// The reconstructed lyric ended at 6.0 but its exit ghost glyphs stay in the live
|
||||
// text until 7.0, while the next authored line is a plain raw event. The retired cue
|
||||
@@ -524,3 +592,85 @@ test('resolvePrimarySubtitleText drops a finished lyric whose exit ghosts outliv
|
||||
'象徴的なパレード',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText stacks simultaneous cues by screen position, not start order', () => {
|
||||
// A top-anchored lyric and bottom dialogue: mpv draws the lyric above the dialogue for
|
||||
// the whole overlap. Whichever event started first must not decide the row, or the
|
||||
// pair swaps every time one side is replaced mid-overlap.
|
||||
const lyricLayout = { kind: 'source-order', sourceOrder: 0, verticalBand: 'top' } as const;
|
||||
const dialogueLayout = { kind: 'source-order', sourceOrder: 1, verticalBand: 'bottom' } as const;
|
||||
const dialogue = {
|
||||
startTime: 632.2,
|
||||
endTime: 634.8,
|
||||
text: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
|
||||
assLayout: dialogueLayout,
|
||||
};
|
||||
|
||||
// Lyric started before the dialogue...
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b\n\u6b4c\u8a5e\uff21',
|
||||
currentTimeSec: 632.5,
|
||||
cues: [
|
||||
{ startTime: 629.5, endTime: 633.5, text: '\u6b4c\u8a5e\uff21', assLayout: lyricLayout },
|
||||
dialogue,
|
||||
],
|
||||
}),
|
||||
'\u6b4c\u8a5e\uff21\n\n\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
|
||||
);
|
||||
// ...and the next lyric starts after it: the rows must not swap.
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b\n\u6b4c\u8a5e\uff22',
|
||||
currentTimeSec: 633.8,
|
||||
cues: [
|
||||
dialogue,
|
||||
{ startTime: 633.5, endTime: 637.0, text: '\u6b4c\u8a5e\uff22', assLayout: lyricLayout },
|
||||
],
|
||||
}),
|
||||
'\u6b4c\u8a5e\uff22\n\n\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText puts an unreadable placement above bottom dialogue', () => {
|
||||
// Dialogue is the case that reliably declares a bottom alignment, so a cue whose
|
||||
// placement could not be read is more often a sign or song line. Keeping dialogue on
|
||||
// the bottom row means the line worth reading stays where the eye already is.
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: '\u4e0b\u306e\u30bb\u30ea\u30d5\n\u4e0d\u660e\u306a\u884c',
|
||||
currentTimeSec: 2,
|
||||
cues: [
|
||||
{
|
||||
startTime: 1,
|
||||
endTime: 3,
|
||||
text: '\u4e0b\u306e\u30bb\u30ea\u30d5',
|
||||
assLayout: { kind: 'source-order', sourceOrder: 0, verticalBand: 'bottom' },
|
||||
},
|
||||
{
|
||||
startTime: 1.5,
|
||||
endTime: 3,
|
||||
text: '\u4e0d\u660e\u306a\u884c',
|
||||
assLayout: { kind: 'source-order', sourceOrder: 1 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
'\u4e0d\u660e\u306a\u884c\n\n\u4e0b\u306e\u30bb\u30ea\u30d5',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText keeps source order when no cue declares a placement', () => {
|
||||
// SRT and websocket cues carry no layout at all: every cue ties, so the stable sort
|
||||
// must leave them exactly as the cue list had them.
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: 'First line\nSecond line',
|
||||
currentTimeSec: 2,
|
||||
cues: [
|
||||
{ startTime: 1, endTime: 3, text: 'First line' },
|
||||
{ startTime: 1.5, endTime: 3, text: 'Second line' },
|
||||
],
|
||||
}),
|
||||
'First line\n\nSecond line',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SubtitleCue } from '../../types';
|
||||
import type { AssVerticalBand, SubtitleCue } from '../../types';
|
||||
import {
|
||||
removeAssControlDebrisLines,
|
||||
removeLiveGlyphFragmentLines,
|
||||
@@ -57,21 +57,52 @@ function compactWhitespace(text: string): string {
|
||||
return text.normalize('NFKC').replace(/\s+/gu, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct simultaneous cues are separated by a blank line so the display layer can tell
|
||||
* a wrap inside one utterance from the boundary between two of them. Consumers that read
|
||||
* the text rather than display it fold these back to single breaks.
|
||||
*/
|
||||
const CUE_BOUNDARY = '\n\n';
|
||||
|
||||
const VERTICAL_BAND_RANK: Record<AssVerticalBand, number> = { top: 0, middle: 1, bottom: 2 };
|
||||
|
||||
/**
|
||||
* Stack simultaneous cues the way they sit on screen: mpv keeps a top-anchored lyric or
|
||||
* sign above bottom dialogue for its whole run, while cue-list order follows start time
|
||||
* and would swap the pair whenever one side is replaced mid-overlap. The band is
|
||||
* constant per event, so a line never changes rows while it is displayed.
|
||||
*
|
||||
* A cue whose placement could not be read -- an unknown style, a script with no styles
|
||||
* section -- sorts to the top. Dialogue is the case that reliably declares a bottom
|
||||
* alignment, so what is left unresolved is more often a sign or a song line, and keeping
|
||||
* the dialogue on the bottom row means the line worth reading stays where the eye
|
||||
* already is. Sort is stable, so cues sharing a rank keep their existing order.
|
||||
*/
|
||||
function orderCuesForDisplay(cues: readonly SubtitleCue[]): SubtitleCue[] {
|
||||
const rank = (cue: SubtitleCue): number =>
|
||||
VERTICAL_BAND_RANK[cue.assLayout?.verticalBand ?? 'top'];
|
||||
return [...cues].sort((a, b) => rank(a) - rank(b));
|
||||
}
|
||||
|
||||
// ASS layers can encode the same visible spacing with ordinary, hard, or
|
||||
// ideographic spaces. Matching and emission must use the same identity or each
|
||||
// layer reappears as a copy.
|
||||
function uniqueCueTexts(cues: readonly SubtitleCue[]): string[] {
|
||||
const texts: string[] = [];
|
||||
function uniqueCueTextGroups(cues: readonly SubtitleCue[]): string[] {
|
||||
const groups: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const cue of cues) {
|
||||
const lines: string[] = [];
|
||||
for (const line of cue.text.split('\n')) {
|
||||
const compactText = compactWhitespace(line);
|
||||
if (!compactText || seen.has(compactText)) continue;
|
||||
seen.add(compactText);
|
||||
texts.push(line);
|
||||
lines.push(line);
|
||||
}
|
||||
if (lines.length > 0) {
|
||||
groups.push(lines.join('\n'));
|
||||
}
|
||||
}
|
||||
return texts;
|
||||
return groups;
|
||||
}
|
||||
|
||||
function compactLineSegments(text: string): string[] {
|
||||
@@ -118,12 +149,15 @@ function resolveActiveParsedPrimarySubtitle(options: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedSegments = selected.flatMap((cue) =>
|
||||
compactLineSegments(cue.text).map((segment) => ({
|
||||
segment,
|
||||
recovered: cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass',
|
||||
})),
|
||||
);
|
||||
const parsedSegments = selected.flatMap((cue) => {
|
||||
const recovered = cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass';
|
||||
return [
|
||||
...compactLineSegments(cue.text).map((segment) => ({ segment, recovered })),
|
||||
...(cue.assFurigana ?? []).flatMap((text) =>
|
||||
compactLineSegments(text).map((segment) => ({ segment, recovered: false })),
|
||||
),
|
||||
];
|
||||
});
|
||||
if (
|
||||
!liveSegments.every((liveSegment) =>
|
||||
parsedSegments.some(({ segment, recovered }) =>
|
||||
@@ -134,23 +168,24 @@ function resolveActiveParsedPrimarySubtitle(options: {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A cue selected only through the edge tolerance has already ended (or not yet
|
||||
// started) by its published timing: a finished lyric whose exit ghosts linger into
|
||||
// the next line. It still explains those live fragments above, but while any cue is
|
||||
// strictly active, only the active cues supply the displayed text. With no strictly
|
||||
// active cue, the edge cues remain the display fallback for stale time-pos readings.
|
||||
const strictlyActive = selected.filter(
|
||||
(cue) => cue.startTime <= options.currentTimeSec && cue.endTime > options.currentTimeSec,
|
||||
);
|
||||
const displayCues = strictlyActive.length > 0 ? strictlyActive : selected;
|
||||
// A cue selected only through the edge tolerance on its end has already finished by
|
||||
// its published timing: a lyric whose exit ghosts linger into the next line. It still
|
||||
// explains those live fragments above, but must not re-surface beside cues that are
|
||||
// still running. The start side keeps the tolerance: mpv publishes the combined
|
||||
// sub-text the moment a joining line's first frame renders, while the observed
|
||||
// time-pos still sits just before that line's start, and the selection above already
|
||||
// required the cue's text to be on screen (#220). With every selected cue finished,
|
||||
// the edge cues remain the display fallback for stale time-pos readings.
|
||||
const unfinished = selected.filter((cue) => cue.endTime > options.currentTimeSec);
|
||||
const displayCues = unfinished.length > 0 ? unfinished : selected;
|
||||
|
||||
// Dense sign grids still explain their raw mpv fragments, but are visual
|
||||
// typesetting rather than a publishable subtitle line.
|
||||
const texts = uniqueCueTexts(
|
||||
displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid'),
|
||||
const groups = uniqueCueTextGroups(
|
||||
orderCuesForDisplay(displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid')),
|
||||
);
|
||||
return {
|
||||
text: texts.join('\n'),
|
||||
text: groups.join(CUE_BOUNDARY),
|
||||
startTime: Math.min(...displayCues.map((cue) => cue.startTime)),
|
||||
endTime: Math.max(...displayCues.map((cue) => cue.endTime)),
|
||||
cues: displayCues,
|
||||
@@ -217,9 +252,9 @@ export function resolveCanonicalPrimarySubtitle(options: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const texts = uniqueCueTexts(selected);
|
||||
const groups = uniqueCueTextGroups(orderCuesForDisplay(selected));
|
||||
return {
|
||||
text: texts.join('\n'),
|
||||
text: groups.join(CUE_BOUNDARY),
|
||||
startTime: Math.min(...selected.map((cue) => cue.startTime)),
|
||||
endTime: Math.max(...selected.map((cue) => cue.endTime)),
|
||||
cues: selected,
|
||||
|
||||
@@ -69,6 +69,11 @@ import type {
|
||||
OverlayNotificationEventPayload,
|
||||
OverlayNotificationPosition,
|
||||
ChangelogSnapshot,
|
||||
MediaTimingReviewActionResult,
|
||||
MediaTimingReviewOpenPayload,
|
||||
MediaTimingReviewPreviewRequest,
|
||||
MediaTimingReviewResolveRequest,
|
||||
MediaTimingReviewWaveformRequest,
|
||||
} from './types';
|
||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||
|
||||
@@ -181,6 +186,11 @@ const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<Youtube
|
||||
IPC_CHANNELS.event.youtubePickerOpen,
|
||||
(payload) => payload as YoutubePickerOpenPayload,
|
||||
);
|
||||
const onOpenMediaTimingReviewEvent =
|
||||
createQueuedIpcListenerWithPayload<MediaTimingReviewOpenPayload>(
|
||||
IPC_CHANNELS.event.mediaTimingReviewOpen,
|
||||
(payload) => payload as MediaTimingReviewOpenPayload,
|
||||
);
|
||||
const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.playlistBrowserOpen);
|
||||
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.youtubePickerCancel,
|
||||
@@ -458,6 +468,19 @@ const electronAPI: ElectronAPI = {
|
||||
onOpenJimaku: onOpenJimakuEvent,
|
||||
onOpenTsukihime: onOpenTsukihimeEvent,
|
||||
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,
|
||||
onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent,
|
||||
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
|
||||
|
||||
@@ -452,6 +452,7 @@ function createKeyboardHandlerHarness() {
|
||||
const testGlobals = installKeyboardTestGlobals();
|
||||
const subtitleRootClassList = createClassList();
|
||||
const subtitleContainerClassList = createClassList();
|
||||
let mediaTimingReviewKeydownCount = 0;
|
||||
let controllerSelectKeydownCount = 0;
|
||||
let openControllerSelectCount = 0;
|
||||
let openControllerDebugCount = 0;
|
||||
@@ -494,6 +495,10 @@ function createKeyboardHandlerHarness() {
|
||||
handleKikuKeydown: () => false,
|
||||
handleJimakuKeydown: () => false,
|
||||
handleTsukihimeKeydown: () => false,
|
||||
handleMediaTimingReviewKeydown: () => {
|
||||
mediaTimingReviewKeydownCount += 1;
|
||||
return false;
|
||||
},
|
||||
handleControllerSelectKeydown: () => {
|
||||
controllerSelectKeydownCount += 1;
|
||||
return true;
|
||||
@@ -523,6 +528,7 @@ function createKeyboardHandlerHarness() {
|
||||
ctx,
|
||||
handlers,
|
||||
testGlobals,
|
||||
mediaTimingReviewKeydownCount: () => mediaTimingReviewKeydownCount,
|
||||
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
||||
openControllerSelectCount: () => openControllerSelectCount,
|
||||
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 () => {
|
||||
const { ctx, testGlobals, handlers, playlistBrowserKeydownCount } =
|
||||
createKeyboardHandlerHarness();
|
||||
|
||||
@@ -18,6 +18,7 @@ export function createKeyboardHandlers(
|
||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleTsukihimeKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleMediaTimingReviewKeydown: (e: KeyboardEvent) => boolean;
|
||||
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||
@@ -1078,6 +1079,11 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (ctx.state.mediaTimingReviewModalOpen) {
|
||||
options.handleMediaTimingReviewKeydown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isKeyboardDrivenModeToggle(e) && ctx.platform.isModalLayer) {
|
||||
e.preventDefault();
|
||||
handleKeyboardModeToggleRequested();
|
||||
|
||||
@@ -196,6 +196,300 @@
|
||||
</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">
|
||||
<div class="media-timing-review-sentence-header">
|
||||
<div class="media-timing-review-sentence-heading">
|
||||
<span class="media-timing-review-sentence-label">Sentence on card</span>
|
||||
<span id="mediaTimingReviewLineCount" class="media-timing-review-line-count">
|
||||
1 line
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
id="mediaTimingReviewLineControls"
|
||||
class="media-timing-review-line-controls hidden"
|
||||
>
|
||||
<div class="media-timing-review-line-stepper">
|
||||
<span>Prev</span>
|
||||
<button
|
||||
id="mediaTimingReviewPrevRemove"
|
||||
type="button"
|
||||
aria-label="Remove the earliest added previous subtitle line from the card sentence"
|
||||
title="Remove the earliest previous line (Shift+P)"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
id="mediaTimingReviewPrevAdd"
|
||||
type="button"
|
||||
aria-label="Add the previous subtitle line to the card sentence"
|
||||
title="Add the previous line (P)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div class="media-timing-review-line-stepper">
|
||||
<span>Next</span>
|
||||
<button
|
||||
id="mediaTimingReviewNextRemove"
|
||||
type="button"
|
||||
aria-label="Remove the latest added next subtitle line from the card sentence"
|
||||
title="Remove the latest next line (Shift+N)"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
id="mediaTimingReviewNextAdd"
|
||||
type="button"
|
||||
aria-label="Add the next subtitle line to the card sentence"
|
||||
title="Add the next line (N)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<blockquote id="mediaTimingReviewText" class="media-timing-review-text"></blockquote>
|
||||
|
||||
<div class="media-timing-review-readout" aria-live="polite">
|
||||
<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>
|
||||
<span class="media-timing-review-original-label is-start" aria-hidden="true">
|
||||
Line start
|
||||
</span>
|
||||
<span class="media-timing-review-original-label is-end" aria-hidden="true">
|
||||
Line end
|
||||
</span>
|
||||
<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="Show two more seconds before the visible timeline without moving the selected clip"
|
||||
title="Show 2 more seconds before the visible timeline. The selected clip does not move."
|
||||
>
|
||||
Earlier −2s
|
||||
</button>
|
||||
<span>Drag an edge to trim, or drag the highlighted clip to move it.</span>
|
||||
<button
|
||||
id="mediaTimingReviewShowLater"
|
||||
class="media-timing-review-expand-button"
|
||||
type="button"
|
||||
aria-label="Show two more seconds after the visible timeline without moving the selected clip"
|
||||
title="Show 2 more seconds after the visible timeline. The selected clip does not move."
|
||||
>
|
||||
Later +2s
|
||||
</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>P</kbd>/<kbd>N</kbd> add prev/next line</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="mediaTimingReviewSkipMedia"
|
||||
class="media-timing-review-skip-button"
|
||||
type="button"
|
||||
title="Keep this card but do not add audio or an image."
|
||||
>
|
||||
Keep without media
|
||||
</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 class="modal-content">
|
||||
<div class="modal-header">
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildMediaTimingLineSelection,
|
||||
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('line selection combines adjacent lines around the mined one and tracks their range', () => {
|
||||
const base = {
|
||||
previousLines: [
|
||||
{ text: '一行目', startTime: 0, endTime: 2 },
|
||||
{ text: '二行目', startTime: 2.5, endTime: 4 },
|
||||
],
|
||||
nextLines: [
|
||||
{ text: '四行目', startTime: 7.5, endTime: 9 },
|
||||
{ text: '五行目', startTime: 9.5, endTime: 11 },
|
||||
],
|
||||
text: '採掘行',
|
||||
originalStartTime: 5,
|
||||
originalEndTime: 7,
|
||||
};
|
||||
|
||||
const none = buildMediaTimingLineSelection({ ...base, previousCount: 0, nextCount: 0 });
|
||||
assert.deepEqual(none.lineTexts, ['採掘行']);
|
||||
assert.equal(none.currentLineIndex, 0);
|
||||
assert.equal(none.rangeStart, 5);
|
||||
assert.equal(none.rangeEnd, 7);
|
||||
|
||||
const expanded = buildMediaTimingLineSelection({ ...base, previousCount: 1, nextCount: 2 });
|
||||
assert.deepEqual(expanded.lineTexts, ['二行目', '採掘行', '四行目', '五行目']);
|
||||
assert.equal(expanded.currentLineIndex, 1);
|
||||
assert.equal(expanded.sentence, '二行目 採掘行 四行目 五行目');
|
||||
assert.equal(expanded.rangeStart, 2.5);
|
||||
assert.equal(expanded.rangeEnd, 11);
|
||||
});
|
||||
|
||||
test('preview request guard blocks overlap and invalidates stale responses', () => {
|
||||
const guard = createMediaTimingPreviewRequestGuard();
|
||||
const first = guard.begin();
|
||||
|
||||
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,858 @@
|
||||
import type {
|
||||
MediaTimingReviewContextLine,
|
||||
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 = 2;
|
||||
const LINE_REVEAL_MARGIN_SECONDS = 1;
|
||||
|
||||
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')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which subtitle lines the card sentence currently includes. The counts say
|
||||
* how many adjacent lines were pulled in on each side; the range covers those lines'
|
||||
* subtitle timings so the clip edges can follow them.
|
||||
*/
|
||||
export function buildMediaTimingLineSelection(options: {
|
||||
previousLines: MediaTimingReviewContextLine[];
|
||||
nextLines: MediaTimingReviewContextLine[];
|
||||
text: string;
|
||||
originalStartTime: number;
|
||||
originalEndTime: number;
|
||||
previousCount: number;
|
||||
nextCount: number;
|
||||
}): {
|
||||
lineTexts: string[];
|
||||
currentLineIndex: number;
|
||||
sentence: string;
|
||||
rangeStart: number;
|
||||
rangeEnd: number;
|
||||
} {
|
||||
const previous =
|
||||
options.previousCount > 0 ? options.previousLines.slice(-options.previousCount) : [];
|
||||
const next = options.nextCount > 0 ? options.nextLines.slice(0, options.nextCount) : [];
|
||||
const lineTexts = [
|
||||
...previous.map((line) => line.text),
|
||||
options.text,
|
||||
...next.map((line) => line.text),
|
||||
];
|
||||
return {
|
||||
lineTexts,
|
||||
currentLineIndex: previous.length,
|
||||
sentence: lineTexts.join(' '),
|
||||
rangeStart: previous[0]?.startTime ?? options.originalStartTime,
|
||||
rangeEnd: next[next.length - 1]?.endTime ?? options.originalEndTime,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMediaTimingPreviewRequestGuard() {
|
||||
let sequence = 0;
|
||||
let activeRequestId: number | null = null;
|
||||
|
||||
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 previousCount = 0;
|
||||
let nextCount = 0;
|
||||
let startPadSeconds = 0;
|
||||
let endPadSeconds = 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 lineRange = currentLineSelection();
|
||||
const originalStartPercent = payload
|
||||
? ((lineRange.rangeStart - timelineStart) / span) * 100
|
||||
: 0;
|
||||
const originalEndPercent = payload ? ((lineRange.rangeEnd - 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 currentLineSelection(): ReturnType<typeof buildMediaTimingLineSelection> {
|
||||
return buildMediaTimingLineSelection({
|
||||
previousLines: payload?.previousLines ?? [],
|
||||
nextLines: payload?.nextLines ?? [],
|
||||
text: payload?.text ?? '',
|
||||
originalStartTime: payload?.originalStartTime ?? 0,
|
||||
originalEndTime: payload?.originalEndTime ?? 0,
|
||||
previousCount,
|
||||
nextCount,
|
||||
});
|
||||
}
|
||||
|
||||
function renderSentence(): void {
|
||||
if (!payload) return;
|
||||
const selection = currentLineSelection();
|
||||
ctx.dom.mediaTimingReviewText.replaceChildren(
|
||||
...selection.lineTexts.map((text, index) => {
|
||||
const line = document.createElement('span');
|
||||
line.className =
|
||||
index === selection.currentLineIndex
|
||||
? 'media-timing-review-line is-current'
|
||||
: 'media-timing-review-line';
|
||||
line.textContent = text;
|
||||
return line;
|
||||
}),
|
||||
);
|
||||
const total = selection.lineTexts.length;
|
||||
ctx.dom.mediaTimingReviewLineCount.textContent = total === 1 ? '1 line' : `${total} lines`;
|
||||
const hasContext = payload.previousLines.length > 0 || payload.nextLines.length > 0;
|
||||
ctx.dom.mediaTimingReviewLineControls.classList.toggle('hidden', !hasContext);
|
||||
ctx.dom.mediaTimingReviewPrevAdd.disabled = previousCount >= payload.previousLines.length;
|
||||
ctx.dom.mediaTimingReviewPrevRemove.disabled = previousCount <= 0;
|
||||
ctx.dom.mediaTimingReviewNextAdd.disabled = nextCount >= payload.nextLines.length;
|
||||
ctx.dom.mediaTimingReviewNextRemove.disabled = nextCount <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or removes an adjacent subtitle line from the card sentence, then follows the
|
||||
* affected clip edge to the new outermost line while keeping the user's other edge.
|
||||
*/
|
||||
function adjustLines(direction: 'previous' | 'next', delta: number): void {
|
||||
if (!payload || resolveInFlight) return;
|
||||
const available =
|
||||
direction === 'previous' ? payload.previousLines.length : payload.nextLines.length;
|
||||
const current = direction === 'previous' ? previousCount : nextCount;
|
||||
const updated = clamp(current + delta, 0, available);
|
||||
if (updated === current) return;
|
||||
if (direction === 'previous') previousCount = updated;
|
||||
else nextCount = updated;
|
||||
|
||||
const selection = currentLineSelection();
|
||||
const mediaEnd = payload.mediaDuration ?? Number.POSITIVE_INFINITY;
|
||||
let timelineChanged = false;
|
||||
let capped = false;
|
||||
if (direction === 'previous') {
|
||||
const target = Math.max(0, selection.rangeStart - startPadSeconds);
|
||||
if (target < timelineStart) {
|
||||
timelineStart = Math.max(0, target - LINE_REVEAL_MARGIN_SECONDS);
|
||||
timelineChanged = true;
|
||||
}
|
||||
updateSelection(target, selectionEnd);
|
||||
capped = selectionStart > target + 0.001;
|
||||
} else {
|
||||
const target = Math.min(mediaEnd, selection.rangeEnd + endPadSeconds);
|
||||
if (target > timelineEnd) {
|
||||
timelineEnd = Math.min(mediaEnd, target + LINE_REVEAL_MARGIN_SECONDS);
|
||||
timelineChanged = true;
|
||||
}
|
||||
updateSelection(selectionStart, target);
|
||||
capped = selectionEnd < target - 0.001;
|
||||
}
|
||||
renderSentence();
|
||||
if (capped && payload.maxMediaDuration > 0) {
|
||||
setStatus(
|
||||
`Clip length is capped at ${payload.maxMediaDuration}s, so the audio cannot cover every added line.`,
|
||||
);
|
||||
}
|
||||
if (timelineChanged) queueWaveformLoad(120);
|
||||
}
|
||||
|
||||
function updateSelection(nextStart: number, nextEnd: number): void {
|
||||
if (!payload) return;
|
||||
const mediaEnd = payload.mediaDuration ?? Number.POSITIVE_INFINITY;
|
||||
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, keep this card without media, use its original timing, or delete it.'
|
||||
: 'Keep editing, create this card without media, use its original timing, or do not create it.';
|
||||
ctx.dom.mediaTimingReviewSkipMedia.textContent =
|
||||
payload.noteId !== undefined ? 'Keep without media' : 'Create without media';
|
||||
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();
|
||||
renderSentence();
|
||||
}
|
||||
}
|
||||
|
||||
function confirmSelection(): void {
|
||||
if (!payload) return;
|
||||
const includesAdjacentLines = previousCount > 0 || nextCount > 0;
|
||||
void resolveReview({
|
||||
action: 'confirm',
|
||||
startTime: selectionStart,
|
||||
endTime: selectionEnd,
|
||||
...(includesAdjacentLines ? { text: currentLineSelection().sentence } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function togglePreview(): Promise<void> {
|
||||
if (!payload || resolveInFlight) return;
|
||||
if (previewPlaying) {
|
||||
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,
|
||||
previousLines: nextPayload.previousLines ?? [],
|
||||
nextLines: nextPayload.nextLines ?? [],
|
||||
};
|
||||
selectionStart = nextPayload.selectionStartTime;
|
||||
selectionEnd = nextPayload.selectionEndTime;
|
||||
timelineStart = nextPayload.timelineStartTime;
|
||||
timelineEnd = nextPayload.timelineEndTime;
|
||||
previousCount = 0;
|
||||
nextCount = 0;
|
||||
startPadSeconds = Math.max(0, nextPayload.originalStartTime - nextPayload.selectionStartTime);
|
||||
endPadSeconds = Math.max(0, nextPayload.selectionEndTime - nextPayload.originalEndTime);
|
||||
resolveInFlight = false;
|
||||
setPreviewPlaying(false);
|
||||
ctx.dom.mediaTimingReviewKind.textContent =
|
||||
nextPayload.kind === 'word'
|
||||
? 'Word card'
|
||||
: nextPayload.kind === 'audio'
|
||||
? 'Audio card'
|
||||
: 'Sentence card';
|
||||
ctx.dom.mediaTimingReviewKind.dataset.kind = nextPayload.kind;
|
||||
ctx.dom.mediaTimingReviewDiscard.textContent =
|
||||
nextPayload.noteId !== undefined ? 'Delete card' : "Don't create card";
|
||||
setStatus('');
|
||||
showEditor();
|
||||
renderSelection();
|
||||
renderSentence();
|
||||
ctx.state.mediaTimingReviewModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
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();
|
||||
confirmSelection();
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(event.key === 'p' || event.key === 'P' || event.key === 'n' || event.key === 'N') &&
|
||||
ctx.dom.mediaTimingReviewCancelStep.classList.contains('hidden') &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
event.preventDefault();
|
||||
adjustLines(
|
||||
event.key === 'p' || event.key === 'P' ? 'previous' : 'next',
|
||||
event.shiftKey ? -1 : 1,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
previousCount = 0;
|
||||
nextCount = 0;
|
||||
updateSelection(payload.selectionStartTime, payload.selectionEndTime);
|
||||
renderSentence();
|
||||
});
|
||||
ctx.dom.mediaTimingReviewPrevAdd.addEventListener('click', () => adjustLines('previous', 1));
|
||||
ctx.dom.mediaTimingReviewPrevRemove.addEventListener('click', () =>
|
||||
adjustLines('previous', -1),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewNextAdd.addEventListener('click', () => adjustLines('next', 1));
|
||||
ctx.dom.mediaTimingReviewNextRemove.addEventListener('click', () => adjustLines('next', -1));
|
||||
ctx.dom.mediaTimingReviewCancel.addEventListener('click', requestCancel);
|
||||
ctx.dom.mediaTimingReviewCancelBack.addEventListener('click', showEditor);
|
||||
ctx.dom.mediaTimingReviewUseOriginal.addEventListener(
|
||||
'click',
|
||||
() => void resolveReview({ action: 'use-original' }),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewSkipMedia.addEventListener(
|
||||
'click',
|
||||
() => void resolveReview({ action: 'skip-media' }),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewDiscard.addEventListener(
|
||||
'click',
|
||||
() => void resolveReview({ action: 'discard' }),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewConfirm.addEventListener('click', () => confirmSelection());
|
||||
}
|
||||
|
||||
return {
|
||||
openMediaTimingReviewModal,
|
||||
requestCancel,
|
||||
handleMediaTimingReviewKeydown,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { createCharacterDictionaryModal } from './modals/character-dictionary.js
|
||||
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
||||
import { createSubsyncModal } from './modals/subsync.js';
|
||||
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
||||
import { createMediaTimingReviewModal } from './modals/media-timing-review.js';
|
||||
import { createPositioningController } from './positioning.js';
|
||||
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
||||
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
|
||||
@@ -114,6 +115,12 @@ const modalDescriptors = [
|
||||
close: () => youtubePickerModal.closeYoutubePickerModal(),
|
||||
suppressesSubtitles: true,
|
||||
},
|
||||
{
|
||||
id: 'media-timing-review',
|
||||
isOpen: () => ctx.state.mediaTimingReviewModalOpen,
|
||||
close: () => mediaTimingReviewModal.requestCancel(),
|
||||
suppressesSubtitles: true,
|
||||
},
|
||||
{
|
||||
id: 'playlist-browser',
|
||||
isOpen: () => ctx.state.playlistBrowserModalOpen,
|
||||
@@ -265,6 +272,10 @@ const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const mediaTimingReviewModal = createMediaTimingReviewModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
||||
@@ -273,6 +284,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||
handleTsukihimeKeydown: tsukihimeModal.handleTsukihimeKeydown,
|
||||
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
|
||||
handleMediaTimingReviewKeydown: mediaTimingReviewModal.handleMediaTimingReviewKeydown,
|
||||
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
|
||||
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||
@@ -574,6 +586,11 @@ function registerModalOpenHandlers(): void {
|
||||
youtubePickerModal.openYoutubePickerModal(payload);
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenMediaTimingReview((payload) => {
|
||||
runGuarded('media-timing-review:open', () => {
|
||||
mediaTimingReviewModal.openMediaTimingReviewModal(payload);
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenPlaylistBrowser(() => {
|
||||
runGuardedAsync('playlist-browser:open', async () => {
|
||||
await playlistBrowserModal.openPlaylistBrowserModal();
|
||||
@@ -805,6 +822,7 @@ async function init(): Promise<void> {
|
||||
jimakuModal.wireDomEvents();
|
||||
tsukihimeModal.wireDomEvents();
|
||||
youtubePickerModal.wireDomEvents();
|
||||
mediaTimingReviewModal.wireDomEvents();
|
||||
playlistBrowserModal.wireDomEvents();
|
||||
kikuModal.wireDomEvents();
|
||||
runtimeOptionsModal.wireDomEvents();
|
||||
|
||||
@@ -64,6 +64,8 @@ export type RendererState = {
|
||||
youtubePickerSecondaryTrackId: string | null;
|
||||
youtubePickerStatus: string;
|
||||
|
||||
mediaTimingReviewModalOpen: boolean;
|
||||
|
||||
kikuModalOpen: boolean;
|
||||
kikuSelectedCard: 1 | 2;
|
||||
kikuOriginalData: KikuDuplicateCardInfo | null;
|
||||
@@ -194,6 +196,8 @@ export function createRendererState(): RendererState {
|
||||
youtubePickerSecondaryTrackId: null,
|
||||
youtubePickerStatus: '',
|
||||
|
||||
mediaTimingReviewModalOpen: false,
|
||||
|
||||
kikuModalOpen: false,
|
||||
kikuSelectedCard: 1,
|
||||
kikuOriginalData: null,
|
||||
|
||||
@@ -1323,6 +1323,795 @@ 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-sentence-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.media-timing-review-sentence-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.media-timing-review-sentence-label {
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.media-timing-review-line-count {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 999px;
|
||||
background: var(--ctp-mantle);
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.media-timing-review-line-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.media-timing-review-line-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.media-timing-review-line-stepper span {
|
||||
margin-right: 2px;
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.media-timing-review-line-stepper button {
|
||||
width: 24px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--ctp-surface2);
|
||||
border-radius: 6px;
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.media-timing-review-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
max-height: 108px;
|
||||
overflow: auto;
|
||||
margin: 0 0 12px;
|
||||
padding: 8px 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-line {
|
||||
color: var(--ctp-overlay1);
|
||||
}
|
||||
|
||||
.media-timing-review-line.is-current {
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
/* Only dim context lines when some are actually added. */
|
||||
.media-timing-review-text .media-timing-review-line:only-child {
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.media-timing-review-readout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
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: 52px;
|
||||
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: 4px 0;
|
||||
width: 100%;
|
||||
height: calc(100% - 8px);
|
||||
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-label {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
padding: 3px 5px;
|
||||
border: 1px solid color-mix(in srgb, var(--ctp-crust) 35%, transparent);
|
||||
border-radius: 4px;
|
||||
background: var(--ctp-peach);
|
||||
box-shadow: 0 2px 6px color-mix(in srgb, var(--ctp-crust) 55%, transparent);
|
||||
color: var(--ctp-crust);
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.media-timing-review-original-label.is-start {
|
||||
top: 5px;
|
||||
left: calc(var(--original-start) + 7px);
|
||||
}
|
||||
|
||||
.media-timing-review-original-label.is-end {
|
||||
right: calc(100% - var(--original-end) + 7px);
|
||||
bottom: 5px;
|
||||
}
|
||||
|
||||
.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-skip-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 {
|
||||
min-width: 82px;
|
||||
padding: 5px 9px;
|
||||
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-skip-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-skip-button {
|
||||
border-color: var(--ctp-sky);
|
||||
color: var(--ctp-sky);
|
||||
}
|
||||
|
||||
.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 {
|
||||
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
||||
transform: translateX(calc(var(--subtitle-sidebar-reserved-width) * -0.5));
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getFrequencyRankLabelForToken,
|
||||
getJlptLevelLabelForToken,
|
||||
normalizeSubtitle,
|
||||
normalizeSubtitleForDisplay,
|
||||
prepareSecondarySubtitleLines,
|
||||
sanitizeSubtitleHoverTokenColor,
|
||||
shouldRenderTokenizedSubtitle,
|
||||
@@ -1004,6 +1005,34 @@ test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks i
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeSubtitleForDisplay always breaks between simultaneous cues', () => {
|
||||
// The blank line marks two distinct cues on screen at once. Flattening it would run a
|
||||
// sign or a second speaker into the line beside it as one sentence.
|
||||
const twoCues =
|
||||
'\u6b21\u306f\u9b3c\u5b50\u6bcd\u795e\u524d\u3000\u9b3c\u5b50\u6bcd\u795e\u524d\n\n\u611b\u97f3\u3061\u3083\u3093\u3000\u3082\u3046\u5199\u771f\u4e0a\u3052\u3066\u308b';
|
||||
|
||||
assert.equal(
|
||||
normalizeSubtitleForDisplay(twoCues, false),
|
||||
'\u6b21\u306f\u9b3c\u5b50\u6bcd\u795e\u524d \u9b3c\u5b50\u6bcd\u795e\u524d\n\u611b\u97f3\u3061\u3083\u3093 \u3082\u3046\u5199\u771f\u4e0a\u3052\u3066\u308b',
|
||||
);
|
||||
assert.equal(normalizeSubtitleForDisplay(twoCues, true), twoCues.replace('\n\n', '\n'));
|
||||
});
|
||||
|
||||
test('normalizeSubtitleForDisplay preserves CRLF boundaries between simultaneous cues', () => {
|
||||
assert.equal(normalizeSubtitleForDisplay('a\r\n\r\nb', false), 'a\nb');
|
||||
});
|
||||
|
||||
test('normalizeSubtitleForDisplay still flattens a wrap inside one cue', () => {
|
||||
// A typesetter's \\N inside a single utterance is what preserveLineBreaks governs.
|
||||
assert.equal(
|
||||
normalizeSubtitleForDisplay(
|
||||
'\u5e38\u4eba\u304c\u4f7f\u3048\u3070\\N\u305d\u306e\u5727\u5012\u7684\u306a\u529b\u306b',
|
||||
false,
|
||||
),
|
||||
'\u5e38\u4eba\u304c\u4f7f\u3048\u3070 \u305d\u306e\u5727\u5012\u7684\u306a\u529b\u306b',
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeSubtitle leaves already-decoded text alone', () => {
|
||||
// Primary subtitle text is decoded from ASS once, upstream: by mpv for live lines and
|
||||
// by the cue parser for prefetched ones. A brace that survives that is literal text.
|
||||
|
||||
@@ -50,6 +50,21 @@ export function normalizeSubtitle(text: string, trim = true, collapseLineBreaks
|
||||
return normalizePlainSubtitleText(text, { trim, collapseLineBreaks });
|
||||
}
|
||||
|
||||
/**
|
||||
* Display form of a resolved subtitle. `preserveLineBreaks` governs wrapping inside one
|
||||
* utterance, which is what a typesetter's `\N` means. The blank line the resolver puts
|
||||
* between two simultaneous cues is a different thing and always breaks, so a sign or a
|
||||
* second speaker never runs into the line beside it.
|
||||
*/
|
||||
export function normalizeSubtitleForDisplay(text: string, preserveLineBreaks: boolean): string {
|
||||
return text
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split(/\n{2,}/)
|
||||
.map((cueText) => normalizeSubtitle(cueText, true, !preserveLineBreaks))
|
||||
.filter((cueText) => cueText.length > 0)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
const SAFE_CSS_COLOR_PATTERN =
|
||||
/^(?:#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgba?|hsla?)\([^)]*\)|var\([^)]*\)|[a-zA-Z]+)$/;
|
||||
@@ -414,16 +429,13 @@ function renderWithTokens(
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
if (sourceText) {
|
||||
const normalizedSource = normalizeSubtitle(sourceText, true, !preserveLineBreaks);
|
||||
const normalizedSource = normalizeSubtitleForDisplay(sourceText, preserveLineBreaks);
|
||||
const segments = alignTokensToSourceText(tokens, normalizedSource);
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.kind === 'text') {
|
||||
if (preserveLineBreaks) {
|
||||
renderPlainTextPreserveLineBreaks(fragment, segment.text);
|
||||
} else {
|
||||
fragment.appendChild(document.createTextNode(segment.text));
|
||||
}
|
||||
// Normalization already resolved which breaks survive; every one left is real.
|
||||
renderPlainTextPreserveLineBreaks(fragment, segment.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -748,7 +760,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
|
||||
const normalized = normalizeSubtitleForDisplay(text, ctx.state.preserveSubtitleLineBreaks);
|
||||
const hasRenderableTokens =
|
||||
shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && Boolean(tokens);
|
||||
if (
|
||||
|
||||
+112
-2
@@ -44,6 +44,46 @@ export type RendererDom = {
|
||||
youtubePickerStatus: HTMLDivElement;
|
||||
youtubePickerTracks: HTMLUListElement;
|
||||
|
||||
mediaTimingReviewModal: HTMLDivElement;
|
||||
mediaTimingReviewKind: HTMLDivElement;
|
||||
mediaTimingReviewText: HTMLElement;
|
||||
mediaTimingReviewLineCount: HTMLElement;
|
||||
mediaTimingReviewLineControls: HTMLDivElement;
|
||||
mediaTimingReviewPrevAdd: HTMLButtonElement;
|
||||
mediaTimingReviewPrevRemove: HTMLButtonElement;
|
||||
mediaTimingReviewNextAdd: HTMLButtonElement;
|
||||
mediaTimingReviewNextRemove: HTMLButtonElement;
|
||||
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;
|
||||
mediaTimingReviewSkipMedia: HTMLButtonElement;
|
||||
mediaTimingReviewDiscard: HTMLButtonElement;
|
||||
|
||||
kikuModal: HTMLDivElement;
|
||||
kikuCard1: HTMLDivElement;
|
||||
kikuCard2: HTMLDivElement;
|
||||
@@ -145,8 +185,8 @@ export type RendererDom = {
|
||||
playlistBrowserClose: HTMLButtonElement;
|
||||
};
|
||||
|
||||
function getRequiredElement<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
function getRequiredElement<T extends Element>(id: string): T {
|
||||
const element = document.querySelector(`#${id}`);
|
||||
if (!element) {
|
||||
throw new Error(`Missing required DOM element #${id}`);
|
||||
}
|
||||
@@ -204,6 +244,76 @@ export function resolveRendererDom(): RendererDom {
|
||||
youtubePickerStatus: getRequiredElement<HTMLDivElement>('youtubePickerStatus'),
|
||||
youtubePickerTracks: getRequiredElement<HTMLUListElement>('youtubePickerTracks'),
|
||||
|
||||
mediaTimingReviewModal: getRequiredElement<HTMLDivElement>('mediaTimingReviewModal'),
|
||||
mediaTimingReviewKind: getRequiredElement<HTMLDivElement>('mediaTimingReviewKind'),
|
||||
mediaTimingReviewText: getRequiredElement<HTMLElement>('mediaTimingReviewText'),
|
||||
mediaTimingReviewLineCount: getRequiredElement<HTMLElement>('mediaTimingReviewLineCount'),
|
||||
mediaTimingReviewLineControls: getRequiredElement<HTMLDivElement>(
|
||||
'mediaTimingReviewLineControls',
|
||||
),
|
||||
mediaTimingReviewPrevAdd: getRequiredElement<HTMLButtonElement>('mediaTimingReviewPrevAdd'),
|
||||
mediaTimingReviewPrevRemove: getRequiredElement<HTMLButtonElement>(
|
||||
'mediaTimingReviewPrevRemove',
|
||||
),
|
||||
mediaTimingReviewNextAdd: getRequiredElement<HTMLButtonElement>('mediaTimingReviewNextAdd'),
|
||||
mediaTimingReviewNextRemove: getRequiredElement<HTMLButtonElement>(
|
||||
'mediaTimingReviewNextRemove',
|
||||
),
|
||||
mediaTimingReviewStartValue: getRequiredElement<HTMLElement>('mediaTimingReviewStartValue'),
|
||||
mediaTimingReviewEndValue: getRequiredElement<HTMLElement>('mediaTimingReviewEndValue'),
|
||||
mediaTimingReviewDuration: getRequiredElement<HTMLElement>('mediaTimingReviewDuration'),
|
||||
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',
|
||||
),
|
||||
mediaTimingReviewSkipMedia: getRequiredElement<HTMLButtonElement>('mediaTimingReviewSkipMedia'),
|
||||
mediaTimingReviewDiscard: getRequiredElement<HTMLButtonElement>('mediaTimingReviewDiscard'),
|
||||
|
||||
kikuModal: getRequiredElement<HTMLDivElement>('kikuFieldGroupingModal'),
|
||||
kikuCard1: getRequiredElement<HTMLDivElement>('kikuCard1'),
|
||||
kikuCard2: getRequiredElement<HTMLDivElement>('kikuCard2'),
|
||||
|
||||
@@ -82,3 +82,19 @@ test('RuntimeOptionsManager keeps known-word and n+1 annotation toggles separate
|
||||
assert.equal(effective.nPlusOne?.enabled, true);
|
||||
assert.deepEqual(patches, []);
|
||||
});
|
||||
|
||||
test('RuntimeOptionsManager applies media timing review to the live Anki config', () => {
|
||||
const baseConfig = structuredClone(DEFAULT_CONFIG.ankiConnect);
|
||||
const patches: unknown[] = [];
|
||||
const manager = new RuntimeOptionsManager(() => structuredClone(baseConfig), {
|
||||
applyAnkiPatch: (patch) => {
|
||||
patches.push(patch);
|
||||
},
|
||||
onOptionsChanged: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(manager.getOptionValue('anki.mediaReviewTiming'), false);
|
||||
assert.equal(manager.setOptionValue('anki.mediaReviewTiming', true).ok, true);
|
||||
assert.equal(manager.getEffectiveAnkiConnectConfig().media?.reviewTiming, true);
|
||||
assert.deepEqual(patches, [{ media: { reviewTiming: true } }]);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ export const OVERLAY_HOSTED_MODALS = [
|
||||
'jimaku',
|
||||
'tsukihime',
|
||||
'youtube-track-picker',
|
||||
'media-timing-review',
|
||||
'playlist-browser',
|
||||
'kiku',
|
||||
'controller-select',
|
||||
@@ -125,6 +126,10 @@ export const IPC_CHANNELS = {
|
||||
syncUiRevealSnapshot: 'sync-ui:reveal-snapshot',
|
||||
syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file',
|
||||
getChangelogSnapshot: 'changelog:get-snapshot',
|
||||
mediaTimingReviewPreview: 'media-timing-review:preview',
|
||||
mediaTimingReviewWaveform: 'media-timing-review:waveform',
|
||||
mediaTimingReviewStopPreview: 'media-timing-review:stop-preview',
|
||||
mediaTimingReviewResolve: 'media-timing-review:resolve',
|
||||
},
|
||||
event: {
|
||||
subtitleSet: 'subtitle:set',
|
||||
@@ -142,6 +147,7 @@ export const IPC_CHANNELS = {
|
||||
jimakuOpen: 'jimaku:open',
|
||||
tsukihimeOpen: 'tsukihime:open',
|
||||
youtubePickerOpen: 'youtube:picker-open',
|
||||
mediaTimingReviewOpen: 'media-timing-review:open',
|
||||
youtubePickerCancel: 'youtube:picker-cancel',
|
||||
playlistBrowserOpen: 'playlist-browser:open',
|
||||
sessionNumericSelectionStart: 'session:numeric-selection-start',
|
||||
|
||||
@@ -53,6 +53,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
|
||||
const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [
|
||||
'anki.autoUpdateNewCards',
|
||||
'anki.mediaReviewTiming',
|
||||
'subtitle.annotation.knownWords.highlightEnabled',
|
||||
'subtitle.annotation.knownWords.maturityEnabled',
|
||||
'subtitle.annotation.nPlusOne',
|
||||
|
||||
@@ -11,6 +11,76 @@ export type CardKind = 'sentence' | 'audio' | 'word-and-sentence' | 'click';
|
||||
/** Card kind SubMiner flags on word cards; 'none' leaves the flag fields untouched. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** A subtitle line adjacent to the mined one that the review can pull onto the card. */
|
||||
export interface MediaTimingReviewContextLine {
|
||||
text: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export type MediaTimingReviewDecision =
|
||||
/** `text` is set when the review combined adjacent lines into the card sentence. */
|
||||
| { action: 'confirm'; startTime: number; endTime: number; text?: string }
|
||||
| { action: 'use-original' }
|
||||
| { action: 'skip-media' }
|
||||
| { action: 'discard' };
|
||||
|
||||
export interface MediaTimingReviewOpenPayload {
|
||||
reviewId: string;
|
||||
kind: MediaTimingReviewKind;
|
||||
text: string;
|
||||
/** Lines before/after the mined one, both chronological: nearest previous line is last, nearest next line is first. */
|
||||
previousLines: MediaTimingReviewContextLine[];
|
||||
nextLines: MediaTimingReviewContextLine[];
|
||||
noteId?: number;
|
||||
originalStartTime: number;
|
||||
originalEndTime: number;
|
||||
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 {
|
||||
body?: string;
|
||||
icon?: string;
|
||||
@@ -85,6 +155,7 @@ export interface AnkiConnectConfig {
|
||||
syncAnimatedImageToWordAudio?: boolean;
|
||||
normalizeAudio?: boolean;
|
||||
mirrorMpvVolume?: boolean;
|
||||
reviewTiming?: boolean;
|
||||
audioPadding?: number;
|
||||
fallbackDuration?: number;
|
||||
maxMediaDuration?: number;
|
||||
|
||||
@@ -248,6 +248,7 @@ export interface ResolvedConfig {
|
||||
syncAnimatedImageToWordAudio: boolean;
|
||||
normalizeAudio: boolean;
|
||||
mirrorMpvVolume: boolean;
|
||||
reviewTiming: boolean;
|
||||
audioPadding: number;
|
||||
fallbackDuration: number;
|
||||
maxMediaDuration: number;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type RuntimeOptionId =
|
||||
| 'anki.autoUpdateNewCards'
|
||||
| 'anki.mediaReviewTiming'
|
||||
| 'subtitle.annotation.knownWords.highlightEnabled'
|
||||
| 'subtitle.annotation.knownWords.maturityEnabled'
|
||||
| 'subtitle.annotation.nPlusOne'
|
||||
|
||||
@@ -3,6 +3,12 @@ import type {
|
||||
KikuFieldGroupingRequestData,
|
||||
KikuMergePreviewRequest,
|
||||
KikuMergePreviewResponse,
|
||||
MediaTimingReviewActionResult,
|
||||
MediaTimingReviewOpenPayload,
|
||||
MediaTimingReviewPreviewRequest,
|
||||
MediaTimingReviewResolveRequest,
|
||||
MediaTimingReviewWaveformRequest,
|
||||
MediaTimingReviewWaveformResult,
|
||||
} from './anki';
|
||||
import type { ChangelogSnapshot } from './changelog';
|
||||
import type { ResolvedConfig, ShortcutsConfig } from './config';
|
||||
@@ -516,6 +522,17 @@ export interface ElectronAPI {
|
||||
onOpenJimaku: (callback: () => void) => void;
|
||||
onOpenTsukihime: (callback: () => 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;
|
||||
onOpenCharacterDictionaryManager: (callback: () => void) => void;
|
||||
onSubtitleSidebarToggle: (callback: () => void) => void;
|
||||
@@ -561,6 +578,7 @@ export interface ElectronAPI {
|
||||
| 'jimaku'
|
||||
| 'tsukihime'
|
||||
| 'youtube-track-picker'
|
||||
| 'media-timing-review'
|
||||
| 'playlist-browser'
|
||||
| 'kiku'
|
||||
| 'controller-select'
|
||||
@@ -577,6 +595,7 @@ export interface ElectronAPI {
|
||||
| 'jimaku'
|
||||
| 'tsukihime'
|
||||
| 'youtube-track-picker'
|
||||
| 'media-timing-review'
|
||||
| 'playlist-browser'
|
||||
| 'kiku'
|
||||
| 'controller-select'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SubtitleCue } from '../core/services/subtitle-cue-parser';
|
||||
import type { AssVerticalBand, SubtitleCue } from '../core/services/subtitle-cue-parser';
|
||||
|
||||
export enum PartOfSpeech {
|
||||
noun = 'noun',
|
||||
@@ -187,7 +187,7 @@ export interface ResolvedTokenPos2ExclusionConfig {
|
||||
|
||||
export type FrequencyDictionaryMode = 'single' | 'banded';
|
||||
|
||||
export type { SubtitleCue };
|
||||
export type { AssVerticalBand, SubtitleCue };
|
||||
|
||||
export type SubtitleSidebarLayout = 'overlay' | 'embedded';
|
||||
|
||||
@@ -243,6 +243,8 @@ export interface SubtitleMiningContext {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
capturedAtMs?: number;
|
||||
/** Explicit generator padding. Confirmed timing-review ranges set this to zero. */
|
||||
mediaPaddingSeconds?: number;
|
||||
}
|
||||
|
||||
export interface SubtitleHoverTokenPayload {
|
||||
|
||||
Reference in New Issue
Block a user