Compare commits

..
12 Commits
Author SHA1 Message Date
sudacode 60432ca2f3 fix(subtitles): suppress overlay duplicates and reset ASS cleanup
- Suppress overlapping decorative karaoke and shadow layer copies
- Clear stale ASS-only fallback sanitization after source refresh failures
2026-08-23 23:02:44 -07:00
sudacode 9044340676 fix(subtitles): recover positioned word gaps in reconstructed translations 2026-08-23 21:37:48 -07:00
sudacode 6d1a1b841a fix(subtitles): suppress sweep stragglers and drop-shadow glyph doubles 2026-08-23 21:11:51 -07:00
sudacode 0ac5db1c92 fix(subtitles): suppress karaoke highlight sweeps from reconstructed lyrics 2026-08-23 21:00:43 -07:00
sudacode 4635bfb264 fix(subtitles): extract embedded subtitle tracks from network-mounted media
The network-mount skip made SMB/NFS libraries fall back to live mpv text for
any release shipping subtitles only inside the container, losing karaoke
reconstruction, sidebar cues, and mining. The starvation it guarded against
had a different cause, and measured extraction runs at wire speed (~10s/GB
on gigabit) once per episode. Skip extraction only for true remote URLs,
which have no on-disk container to demux, and raise the extraction timeout
to cover large Bluray remuxes read over the network.
2026-08-23 20:45:30 -07:00
sudacode 1717d2d3f2 fix(subtitles): suppress per-glyph typesetting walls in live subtitle text
When embedded-track extraction is skipped (network-mounted media), live mpv
text during per-glyph typeset karaoke is a wall of simultaneous one-glyph
lines plus the syllable being typed. No parsed cues exist to substitute, so
the wall reached both overlays and recording verbatim. Detect bursts of many
single-glyph lines in the live fallback paths and drop them with their short
syllable companions, keeping concurrent dialogue lines.
2026-08-23 20:33:13 -07:00
sudacode 9f08adbfb9 fix(subtitles): drop symbol-font glyph decoration and recover wide-glyph word gaps
Generated lyric effects can overlay each syllable with animated single letters
rendered through \fn in a symbol font, where ordinary letters draw as sparkles.
Reading them as text corrupted reconstructed lines ("sotto mimi ni ateru to a z
x") and leaked junk cues ("hlk"). A font a style group uses only for scattered
animated single glyphs now marks those events as decoration: they stay out of
fragment reconstruction and are suppressed alongside the line they overlay.

Per-glyph word gaps measured across a wide glyph ("waves|within" over s/w)
normalize to nearly a common advance, so the ratio test missed them. A word
space adds a roughly constant extra distance regardless of neighbor widths, so
glyph runs with enough gap samples also split when the advance exceeds the
width-predicted advance by a material fraction of the line's common unit.
Capital-to-lowercase pairs and short sample counts are excluded; both guards
are pinned by corpus-derived regression tests.

Across the 145-file library corpus this removes every scattered-letter
malformation and recovers 30+ missing word spaces with no other output change.
2026-08-23 20:33:04 -07:00
sudacode 6f52008e5d fix(subtitles): keep dialogue and wrapped lyrics out of ASS fragment gri
- Only classify tall positioned ASS blocks as fragment grids when they read like tiling (sign walls, re-shown countdown frames, scattered single glyphs, or table columns), keeping CC-style dialogue blocks and wrapped lyric rows publishable
- Widen Latin word-gap heuristics for per-glyph typesetting runs and short capitalized words so proportional-font variation and two-letter words no longer get split
- Add changelog fragment documenting the stats database busy-timeout fix
2026-08-23 19:16:55 -07:00
sudacode c4284d1dd4 fix(stats): avoid transient SQLite worker lock 2026-08-23 17:34:16 -07:00
sudacode da2a212434 fix(subtitles): reset ASS fallback state on secondary disconnect
- Clear activeSourceUsesAssSyntax when mpv disconnects so stale ASS
  sanitization doesn't leak into live fallback text after reconnect
2026-08-23 17:16:49 -07:00
sudacode faab084588 fix(subtitles): only strip ASS control debris for ASS sources
- Skip control-debris stripping for primary/secondary live text when cues or source aren't ASS/SSA, so SRT lines that merely resemble ASS override tags survive
- Track active source's ASS-ness in the secondary controller and gate handleLiveText's cleanup on it
2026-08-23 16:54:43 -07:00
sudacode c87dcd6239 fix(subtitles): recover positioned ASS word spacing and drop control deb
- Recover Latin/romaji word spaces encoded only by positioned `\pos`/`\move` fragment gaps in ASS karaoke
- Drop malformed ASS rotation-reset spacer lines and skip zero-duration metadata events
- Dedupe duplicate lines across multiline primary/secondary cues, including full-width variants
- Treat dense fragment-grid sign layouts as visual typesetting, not publishable text, during primary subtitle resolution
2026-08-23 16:34:36 -07:00
67 changed files with 293 additions and 2801 deletions
-56
View File
@@ -1,61 +1,5 @@
# 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
- **Library Merge & Move**: Duplicate library cards for the same show can now be combined. Select cards in the library grid and use "Merge Selected" to pick which entry to keep and move every episode onto it, preserving sessions, mined cards, and watch time. Episodes can also be reassigned individually via the "→" button, useful when a file lands under a stray title; manual assignments survive later filename parsing, Jellyfin refreshes, and season repair. Exact AniList title matches with compatible seasons now merge automatically, while fuzzy matches surface as dismissible "Possible duplicate" reviews instead of merging silently.
- **Duplicate Line Cleanup Tool**: The Vocabulary tab's new "Duplicates" button scans a chosen time window (7 days through all time) for old karaoke/typeset duplicate-line bursts, shows what it found, and collapses each run to one line once confirmed; `subminer stats cleanup --duplicate-lines` does the same from the terminal, with `--dry-run` and `--lookback-days <n>` options. Watch time and lines-seen totals are left unchanged.
### Changed
- **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**:
- 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.
- **Stats Performance & Reliability**: Immersion stats storage now sets its SQLite busy timeout before WAL setup, avoiding transient lock errors under concurrent writes. Deletes in the stats dashboard no longer freeze the UI, run proportional to what's deleted instead of rebuilding full lifetime summaries, retry safely if the delete worker crashes, and no longer rescan the whole library when deleting very common words; a new index also makes large session deletes drop from minutes to milliseconds. Library merges, video moves, and AniList reassignments got the same lifetime-summary fix.
- **Vocabulary Stats Accuracy**: Vocabulary totals and charts now count all tracked vocabulary instead of only the first page, new-word history uses corrected daily rollups (fixing legacy timestamp and time-zone issues), summary cards refresh automatically after edits to the exclusion list, and rapid exclusion edits no longer race each other.
- **Rofi MKV Thumbnails**: Fixed missing MKV thumbnails in the Linux rofi picker when system thumbnailer registrations only advertise legacy Matroska MIME aliases.
<details>
<summary>Internal changes</summary>
### Internal
- Docs Site Indexing: Excluded the `/main/` and `/v/<version>/` docs trees from search indexing (self-referential canonical, `noindex,follow`, matching `X-Robots-Tag`) so crawlers focus on current docs instead of ~30 archived copies of every page, and restored `<lastmod>` dates in the docs sitemap that were silently dropped by production builds.
</details>
## v0.19.3 (2026-08-13)
### Added
+1 -1
View File
@@ -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.
- `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.
- 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.
- `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.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: subtitles
- Typeset ASS karaoke and animated signs no longer flood the primary overlay, subtitle sidebar, immersion history, or sentence mining with repeated glyph fragments or full-line color phases. Matching timed comments and full-line boundary events recover the complete authored line without merging ordinary repeated dialogue or separately positioned signs, and dialogue spoken while a song's animation is on screen is kept intact instead of being replaced by the lyric. Entrance and exit frames that run past the authored line timing still resolve to the clean line during lyric transitions, and dialogue spoken while a song's animation is on screen enters immersion and subtitle history without the fragment lines beside it. Dense visual grids (sign walls, countdown frames, scattered glyph typesetting) stay out of the published text, while multi-row CC-style dialogue blocks and wrapped lyric rows are still published. Decorative letters that lyric effects render in symbol fonts over the syllables are dropped with the animation instead of corrupting the reconstructed line or leaking as stray cues. Karaoke highlight sweeps that repaint one syllable at a time over an already-visible lyric are suppressed instead of surfacing as rolling partial copies or lone flickering syllables beside the line, and drop-shadow glyph copies offset a few pixels from their base no longer double every syllable in the reconstructed lyric. Positioned word gaps are also recovered on lines where a single fragment carries a literal space, and between wide syllable chunks whose word gap is hidden by their own width, so reconstructed translations keep their spacing instead of running words together.
- The secondary subtitle overlay drops layered duplicate lines from animated tracks, so a short stack of repeated words collapses to its distinct lines even when the full karaoke heuristic does not apply.
@@ -0,0 +1,4 @@
type: fixed
area: Anki media
- Fixed sentence-audio generation timing out on slow network-mounted MKV files with many subtitle and font-attachment streams. Selected audio tracks now use bounded FFmpeg probing and a two-minute extraction budget, and missing output reports a clear FFmpeg error instead of raw `ENOENT`.
@@ -0,0 +1,5 @@
type: fixed
area: character dictionary
- Reuse character dictionaries after MeCab completes without finding any name splits instead of regenerating character data and portraits on every launch.
- Restore inline character portraits when a cached portrait index finishes loading after subtitles have already been tokenized.
@@ -0,0 +1,5 @@
type: fixed
area: subtitles
- Embedded subtitle tracks on network-mounted (SMB/NFS) media are extracted and parsed again, restoring full karaoke reconstruction, sidebar cues, and mining for releases that ship subtitles only inside the container. Extraction reads the whole file once per episode (roughly 10 seconds per GB on gigabit), its timeout now accommodates large Bluray remuxes, and duplicate extraction requests share one ffmpeg process. Only true remote URLs keep the live-text-only path.
- Live subtitle text from per-glyph typeset karaoke no longer shows a wall of scattered letters in the overlays while extraction is still running or when no parsed cues exist (remote URLs, unreadable sources); the glyph wall and its typed-syllable fragments are suppressed while concurrent dialogue lines remain.
@@ -0,0 +1,5 @@
type: fixed
area: dictionary
- Character dictionary generation, merged rebuilds, and imports no longer freeze the app (and trigger the compositor's "application not responding" dialog) on large dictionaries; snapshot reads/writes, archive building, and the character image/name lookup caches now do their heavy work off the UI's critical path.
- Desktop progress notifications now update in place on Linux AppImage installs too: the AppImage's bundled libraries broke the system notify-send helper, which silently forced the flickering close-and-reopen notification fallback.
+5
View File
@@ -0,0 +1,5 @@
type: internal
area: docs
- Excluded the `/main/` and `/v/<version>/` docs trees from search indexing with a self-referential canonical, `noindex,follow`, and a matching `X-Robots-Tag` header, so crawlers spend their budget on the current docs instead of ~30 archived copies of every page.
- Restored `<lastmod>` dates in the docs sitemap, which were silently dropped because production builds render from an untracked release snapshot.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: stats
- Typeset subtitles no longer flood the stats. Karaoke openings and animated signs are authored as one subtitle event per animation frame, and immersion tracking counted every frame, which was enough to put an OP lyric at the top of "Top Repeated Words" for good. Lines are now collapsed on the way in using the same rules the subtitle sidebar already applies: matching parsed timings record exactly the cues the sidebar shows, while shifted, changing, or unparsed sources use a strict fallback where identical, contiguous, sub-0.1s lines stop counting after a few frames. Ordinary repeated dialogue and rewatches are unaffected.
- Added a cleanup for stats already affected. The Vocabulary tab has a **Duplicates** button that scans a chosen window (7 days through all time), shows the bursts it found and the word and kanji counts they added, and collapses each run to one line once confirmed. `subminer stats cleanup --duplicate-lines` does the same from the terminal, with `--dry-run` and `--lookback-days <n>`. Only subtitle lines and the vocabulary counts they feed are touched; watch time and lines-seen totals are left as recorded.
@@ -0,0 +1,5 @@
type: fixed
area: overlay
- Dedicated overlay modals are prewarmed on macOS and Windows so shortcuts open them promptly on the first press. Windows now refreshes the hidden modal renderer between sessions to keep later modals interactive. On macOS, reused modals and the in-app stats window also open above fullscreen mpv on its current Space instead of appearing on another desktop or forcing a Space change.
- Updated subtitle ASS observation to mpv's current `sub-text/ass` property, removing its deprecation warning.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- The macOS window-tracking helper is now built for macOS 12.0+, so the overlay attaches to mpv on older systems (previously the helper required the macOS version of the build machine and crashed on e.g. Ventura, leaving the overlay stuck on "Overlay loading").
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed the overlay getting stuck on "Overlay loading" forever when startup stalls: mpv IPC connection attempts now time out and retry, switching sockets aborts obsolete attempts, and the plugin replaces its spinner with an actionable error if overlay content is still not ready after 30 seconds.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- 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.
@@ -0,0 +1,4 @@
type: fixed
area: subtitles
- Primary and secondary ASS subtitles now collapse layered and whitespace variants of full-span lyrics, including when playback starts or seeks into a line, reconstruct fragment-only karaoke per style, preserve authored stack order, keep canonical signs visible for their complete generated animation, navigate song lyrics by sanitized lines instead of generated animation events, and keep sidebar selections on the requested overlapping lyric while preserving unmatched dialogue and signs.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Fragmented ASS karaoke keeps spaces authored at event boundaries and recovers Latin word spaces encoded only by positioned fragment gaps, including word gaps measured across wide glyphs that width normalization alone reads as ordinary letter advances. Progressive karaoke highlights, offset shadow copies, and overlapping decorative glyphs remain suppressed. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become concatenated primary or secondary lines. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display. A failed source refresh also clears ASS-only cleanup so fallback text from other formats stays intact.
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Immersion statistics storage now applies its SQLite busy timeout before WAL setup, avoiding transient database-lock failures when worker connections overlap.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed system-wide mouse lag on Windows while SubMiner is running: the overlay no longer installs Electron's global mouse hook for click-through forwarding, and the mpv window tracker no longer blocks the app on repeated PowerShell command-line lookups.
+6
View File
@@ -0,0 +1,6 @@
type: added
area: stats
- Library: duplicate cards for the same show can now be combined. Press "Select" above the library grid, tick the cards, and use "Merge Selected"; the dialog picks which entry to keep and moves every episode onto it. Sessions, mined cards, and watch time are preserved, the emptied entries disappear, and remembered title aliases keep future episodes on the merged card.
- Library: episodes can be reassigned to another library entry from the "→" button on an episode row, which is the fix when one file lands under a stray title (e.g. an episode name parsed as the series). Manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair. Local episodes in the same directory reuse a uniquely corrected destination unless they parse to a title that already has its own library entry, while conflicting seasons or manual destinations are not forced together. Emptying an entry this way removes it and returns to the grid.
- Library: exact AniList title matches with compatible seasons fold duplicate cards automatically. Fuzzy same-AniList matches appear as dismissible "Possible duplicate" reviews instead of changing the library without confirmation; conflicting explicit seasons are left alone.
@@ -0,0 +1,4 @@
type: fixed
area: notifications
- Character dictionary progress notifications on Linux now update in place instead of flickering off and reappearing on every status change.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Mined audio and animated AVIF clips now capture the subtitle line that was actually mined. The clip range is snapshotted once at Yomitan lookup time (and reused for both audio and image), instead of each generator reading the live mpv subtitle when it starts — which clipped whatever line was on screen after slow audio extraction finished, producing too-short or misaligned AVIF clips.
+5
View File
@@ -0,0 +1,5 @@
type: changed
area: release
- Prerelease notes now open with a "Changes since" section that lists only what changed compared to the previous beta/RC of the same version, above the cumulative highlights.
- CI now rejects prerelease tags whose committed notes were generated for a different beta/RC, instead of silently shipping stale notes.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Secondary subtitle overlays now show every rendered line instead of clipping text after roughly four lines.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Fixed missing MKV thumbnails in the Linux rofi picker when system thumbnailer registrations only advertise legacy Matroska MIME aliases.
+9
View File
@@ -0,0 +1,9 @@
type: fixed
area: stats
- Stats deletes no longer freeze the stats dashboard: the delete worker module now resolves when running from source, so deletes actually run off the serving thread instead of silently falling back to it.
- Deletes now subtract their exact contribution from lifetime summaries instead of rebuilding them from retained sessions, making delete cost proportional to what is deleted and preserving lifetime totals older than the session retention window.
- If the delete worker crashes, the delete now retries on the current thread instead of failing.
- Library merges, video moves, AniList reassignments, and `subminer stats cleanup -l` also stopped rebuilding lifetime summaries from retained sessions; they now recompute from per-episode history, so those operations are faster and no longer erase lifetime totals older than the session retention window.
- Deleting content that contains very common words no longer rescans every occurrence of those words across the whole library; first/last-seen dates are refreshed with index seeks instead.
- Session deletes on large databases dropped from minutes to milliseconds: an index on the subtitle-line event reference now prevents each deleted session event from scanning the whole subtitle-line table for foreign-key enforcement.
@@ -0,0 +1,8 @@
type: fixed
area: stats
- Fixed Vocabulary totals and charts counting only the first browsing page instead of all tracked vocabulary, without delaying the rest of the page.
- New-word history now uses permanent daily lexical rollups that apply the same vocabulary filters as the totals and normalize legacy second/millisecond timestamps; versioned background rebuilds repair existing history across legacy rollup-state schemas without dropping playback writes or clearing watch-time, activity, efficiency, and library charts.
- Calendar-day chart labels now preserve the recorded local date in time zones west of UTC.
- Vocabulary summary cards and charts refresh automatically after the word exclusion list changes, and failed or unfinished loads use bounded retries before showing an inline error with a Retry control.
- Rapid exclusion edits no longer race each other; writes are sent in order so a slower earlier save cannot overwrite a newer list.
+1 -5
View File
@@ -136,8 +136,6 @@ 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
@@ -235,7 +233,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 in `ankiConnect.fields.audio`, even when `overwriteAudio` is disabled.
`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.
## AI Translation
@@ -289,8 +287,6 @@ 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 (19) to select how many recent lines to combine.
## Word Card Type (Kiku/Lapis)
-56
View File
@@ -1,61 +1,5 @@
# 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**
- **Library Merge & Move**: Duplicate library cards for the same show can now be combined. Select cards in the library grid and use "Merge Selected" to pick which entry to keep and move every episode onto it, preserving sessions, mined cards, and watch time. Episodes can also be reassigned individually via the "→" button, useful when a file lands under a stray title; manual assignments survive later filename parsing, Jellyfin refreshes, and season repair. Exact AniList title matches with compatible seasons now merge automatically, while fuzzy matches surface as dismissible "Possible duplicate" reviews instead of merging silently.
- **Duplicate Line Cleanup Tool**: The Vocabulary tab's new "Duplicates" button scans a chosen time window (7 days through all time) for old karaoke/typeset duplicate-line bursts, shows what it found, and collapses each run to one line once confirmed; `subminer stats cleanup --duplicate-lines` does the same from the terminal, with `--dry-run` and `--lookback-days <n>` options. Watch time and lines-seen totals are left unchanged.
**Changed**
- **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**:
- 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.
- **Stats Performance & Reliability**: Immersion stats storage now sets its SQLite busy timeout before WAL setup, avoiding transient lock errors under concurrent writes. Deletes in the stats dashboard no longer freeze the UI, run proportional to what's deleted instead of rebuilding full lifetime summaries, retry safely if the delete worker crashes, and no longer rescan the whole library when deleting very common words; a new index also makes large session deletes drop from minutes to milliseconds. Library merges, video moves, and AniList reassignments got the same lifetime-summary fix.
- **Vocabulary Stats Accuracy**: Vocabulary totals and charts now count all tracked vocabulary instead of only the first page, new-word history uses corrected daily rollups (fixing legacy timestamp and time-zone issues), summary cards refresh automatically after edits to the exclusion list, and rapid exclusion edits no longer race each other.
- **Rofi MKV Thumbnails**: Fixed missing MKV thumbnails in the Linux rofi picker when system thumbnailer registrations only advertise legacy Matroska MIME aliases.
<details>
<summary>Internal changes</summary>
**Internal**
- Docs Site Indexing: Excluded the `/main/` and `/v/<version>/` docs trees from search indexing (self-referential canonical, `noindex,follow`, matching `X-Robots-Tag`) so crawlers focus on current docs instead of ~30 archived copies of every page, and restored `<lastmod>` dates in the docs sitemap that were silently dropped by production builds.
</details>
## v0.19.3 (2026-08-13)
**Added**
+1 -1
View File
@@ -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 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.
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.
This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card.
@@ -87,9 +87,7 @@ interface SubtitleCue {
ASS scripts can also redraw one complete lyric for two or more long color/highlight phases. Those flush-timed phases collapse separately from short animation frames when they share text, style, actor, and layer and carry direct animation evidence, such as temporal tags or changing non-spatial overrides. Spatial command changes do not prove a phase, so separately positioned signs remain distinct.
**Canonical animation recovery.** Some ASS producers keep the readable lyric or sign as a timed `Comment:` and generate hundreds of `Dialogue:` frames containing repeated glyphs or changing clip regions. Others retain the complete line as brief `Dialogue:` events around the generated fragments. A complete event is promoted only when nearby dialogue from the same style and actor forms a proven animation cluster and reconstructs its entire text in source order. The generated frames are then replaced by one cue marked `source: 'canonical-ass'`. This source marker lets the live primary-subtitle path prefer the clean authored text and timing for display, sidebar history, immersion recording, and mining, while unmatched editor notes and alternative translations remain ignored. Secondary selection advances to an entering canonical cue at its generated animation start when the preceding authored cue ends before the new authored span. Unrelated simultaneous cues that continue through the new span remain visible.
**Font texture cleanup.** A clipped repeated-glyph run or frequent changes to secondary alpha marks a texture seed. Clipped runs do not need a font override because some signs build their masks from ordinary `l` glyphs. The parser removes short clipped pieces that share a no-font seed's style and timing, or pieces that share a font seed's style, timing, and font even when the actor changes. It also removes positioned text layers with at least `E0` global alpha when they overlap a seed in the same style. Opaque authored sign text stays publishable when the texture switches fonts or actors around it.
**Canonical animation recovery.** Some ASS producers keep the readable lyric or sign as a timed `Comment:` and generate hundreds of `Dialogue:` frames containing repeated glyphs or changing clip regions. Others retain the complete line as brief `Dialogue:` events around the generated fragments. A complete event is promoted only when nearby dialogue from the same style and actor forms a proven animation cluster and reconstructs its entire text in source order. The generated frames are then replaced by one cue marked `source: 'canonical-ass'`. This source marker lets the live primary-subtitle path prefer the clean authored text and timing for display, sidebar history, immersion recording, and mining, while unmatched editor notes and alternative translations remain ignored.
#### Prefetch Service Lifecycle
@@ -129,9 +129,6 @@ 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
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.19.5",
"version": "0.19.4-beta.4",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
-33
View File
@@ -1,33 +0,0 @@
## Highlights
### Fixed
- **Anki Card Update Progress**: The update spinner now stays visible until audio and image updates actually finish, so you won't mistake an in-progress update for a failure.
- **Word-Card Field Enrichment**: Word-card enrichment now reliably writes sentence text and audio into whichever AnkiConnect fields you've configured, while the dedicated Lapis/Kiku sentence-card and audio-card actions still use their expected field names.
- **Overlapping Subtitles**:
- Lines that start while another is still on screen now show together instead of staying hidden until you switch tracks or seek.
- Subtitles shown at the same time now stack by their authored screen position, with signs and song lyrics above dialogue.
- Half-size ASS furigana no longer shows up as if it were its own subtitle line.
- **YouTube Auto-Generated Captions**:
- Captions now follow their intended timing instead of drifting off sync.
- Long speech is paged across two rows instead of piling into a wall of text.
- Timed sound cues like `[音楽]` no longer linger over later dialogue.
## What's Changed
- fix(anki): keep overlay progress visible through card updates by @ksyasuda in #218
- fix(youtube): keep auto captions on screen for their full span by @ksyasuda in #219
- fix(subtitles): keep overlapping lines that join an already active cue by @ksyasuda in #221
- fix(anki): respect configured fields for word-card enrichment by @ksyasuda in #223
## 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`.
+4 -13
View File
@@ -44,22 +44,14 @@ function fragmentTypesInPrompt(input: string): string[] {
.map((line) => line.slice('type: '.length).trim());
}
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/);
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/);
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);
@@ -454,7 +446,6 @@ 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(
+3 -8
View File
@@ -480,15 +480,10 @@ 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 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.
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.
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.
+3 -83
View File
@@ -11,12 +11,10 @@ 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 }>;
};
@@ -608,7 +606,6 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
const integration = new AnkiIntegration(
{
fields: {
audio: 'ExpressionAudio',
image: 'Picture',
},
media: {
@@ -662,7 +659,7 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
noteIds.map((noteId) => ({
noteId,
fields: {
ExpressionAudio: { value: '' },
SentenceAudio: { value: '' },
Picture: { value: '' },
},
})),
@@ -947,7 +944,7 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
noteInfo: {
noteId: 404,
fields: {
ExpressionAudio: { value: '' },
SentenceAudio: { value: '' },
Picture: { value: '' },
},
},
@@ -959,8 +956,7 @@ 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.ExpressionAudio ?? '', /^\[sound:audio_/);
assert.equal(updatedNotes[0]?.fields.SentenceAudio, undefined);
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/);
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
assert.equal(storedMedia.length, 2);
assert.deepEqual(audioVolumeScales, [0.3 ** 3]);
@@ -1186,82 +1182,6 @@ 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 keeps overlay notification image when temp icon write fails', async () => {
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
const overlayNotifications: TestOverlayNotificationPayload[] = [];
+13 -14
View File
@@ -218,8 +218,6 @@ 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>();
@@ -267,7 +265,6 @@ 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 };
@@ -283,7 +280,6 @@ 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();
@@ -383,6 +379,8 @@ 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) =>
@@ -659,6 +657,8 @@ 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),
@@ -1203,13 +1203,12 @@ 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: true,
persistent: false,
});
}
return;
@@ -1221,10 +1220,6 @@ export class AnkiIntegration {
private endUpdateProgress(): void {
if (!this.shouldUseOsdNotifications()) {
if (this.overlayUpdateProgressActive) {
this.overlayUpdateProgressActive = false;
this.overlayNotificationDismissCallback?.('anki-update-progress');
}
return;
}
endUpdateProgress(this.uiFeedbackState, (timer) => {
@@ -1248,20 +1243,18 @@ 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: true,
persistent: false,
});
}
try {
return await action();
} finally {
this.updateInProgress = false;
this.endUpdateProgress();
}
}
return withUpdateProgress(
@@ -1360,7 +1353,6 @@ export class AnkiIntegration {
: undefined;
if (shouldShowOverlayNotification && this.overlayNotificationCallback) {
this.overlayUpdateProgressActive = false;
this.overlayNotificationCallback({
id: 'anki-update-progress',
title: 'Anki Card Updated',
@@ -1563,6 +1555,13 @@ 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);
}
@@ -143,7 +143,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
};
}
test('manual clipboard subtitle update replaces audio in the configured field', async () => {
test('manual clipboard subtitle update replaces sentence audio without touching expression audio', async () => {
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService();
await service.updateLastAddedFromClipboard('字幕');
@@ -151,144 +151,14 @@ test('manual clipboard subtitle update replaces audio in the configured field',
assert.equal(updatedFields.length, 1);
assert.equal(storedMedia.length, 1);
const audioValue = `[sound:${storedMedia[0]}]`;
assert.equal(updatedFields[0]?.ExpressionAudio, audioValue);
assert.equal('SentenceAudio' in updatedFields[0]!, false);
assert.equal(updatedFields[0]?.SentenceAudio, audioValue);
assert.equal('ExpressionAudio' 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 () => '',
},
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 () => '',
},
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: () =>
@@ -355,7 +225,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
});
});
test('manual clipboard subtitle update uses configured audio when SentenceAudio is missing', async () => {
test('manual clipboard subtitle update skips audio when sentence audio field is missing', async () => {
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({
client: {
addNote: async () => 0,
@@ -385,9 +255,8 @@ test('manual clipboard subtitle update uses configured audio when SentenceAudio
assert.equal(storedMedia.length, 1);
assert.equal(updatedFields.length, 1);
assert.match(updatedFields[0]?.ExpressionAudio ?? '', /^\[sound:audio_\d+\.mp3\]$/);
assert.equal(updatedFields[0]?.Sentence, '字幕');
assert.equal(mergeCalls.length, 1);
assert.deepEqual(updatedFields[0], { Sentence: '字幕' });
assert.equal(mergeCalls.length, 0);
});
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
+21 -11
View File
@@ -260,16 +260,9 @@ export class CardCreationService {
fields,
this.deps.getConfig(),
);
const config = this.deps.getConfig();
const sentenceAudioField = this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
);
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceField = this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.sentence ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence,
);
const sentenceField = sentenceCardConfig.sentenceField;
const sentence = blocks.join(' ');
const updatedFields: Record<string, string> = {};
@@ -291,6 +284,7 @@ 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();
@@ -463,13 +457,13 @@ export class CardCreationService {
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), 'audio');
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceField = sentenceCardConfig.sentenceField;
const sentenceField = this.deps.getConfig().fields?.sentence;
if (sentenceField) {
const processedSentence = this.deps.processSentence(mpvClient.currentSubText, fields);
updatedFields[sentenceField] = processedSentence;
}
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const audioFieldName = sentenceCardConfig.audioField;
try {
const audioFilename = this.generateAudioFilename();
@@ -812,6 +806,22 @@ 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,
@@ -80,6 +80,7 @@ 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',
@@ -119,49 +120,6 @@ 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 () =>
@@ -474,7 +432,6 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
harness.deps.getConfig = () => ({
fields: {
sentence: 'Sentence',
audio: 'SentenceAudio',
image: 'Picture',
miscInfo: 'MiscInfo',
},
@@ -487,6 +444,7 @@ 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');
@@ -543,7 +501,6 @@ test('NoteUpdateWorkflow snapshots one media range for audio and image without a
harness.deps.getConfig = () => ({
fields: {
sentence: 'Sentence',
audio: 'SentenceAudio',
image: 'Picture',
miscInfo: 'MiscInfo',
},
@@ -554,6 +511,7 @@ test('NoteUpdateWorkflow snapshots one media range for audio and image without a
},
behavior: {},
});
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
harness.deps.captureSubtitleMediaContext = () => {
captureCalls += 1;
return capturedContext;
+5 -10
View File
@@ -19,7 +19,6 @@ export interface NoteUpdateWorkflowDeps {
fields?: {
word?: string;
sentence?: string;
audio?: string;
image?: string;
miscInfo?: string;
};
@@ -76,6 +75,7 @@ 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;
@@ -198,13 +198,11 @@ export class NoteUpdateWorkflow {
const updatedFields: Record<string, string> = {};
let updatePerformed = false;
let miscInfoFilename: string | null = null;
const configuredSentenceField =
config.fields?.sentence ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence;
const sentenceField = this.deps.resolveConfiguredFieldName(noteInfo, configuredSentenceField);
const sentenceField = sentenceCardConfig.sentenceField;
const subtitleMiningContext = this.consumeMatchingSubtitleMiningContext(
fields,
sentenceField ?? configuredSentenceField,
configuredSentenceField,
sentenceField,
config.fields?.sentence,
);
// 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
@@ -260,10 +258,7 @@ export class NoteUpdateWorkflow {
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
const sentenceAudioField = this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
);
const sentenceAudioField = this.deps.getResolvedSentenceAudioFieldName(noteInfo);
if (sentenceAudioField) {
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
@@ -31,6 +31,7 @@ function createDeps(
getCachedMediaPath: async () => null,
shouldRequireRemoteMediaCache: () => true,
getSubtitleMediaRange: () => ({ startTime: 1, endTime: 2 }),
getResolvedSentenceAudioFieldName: () => 'SentenceAudio',
resolveConfiguredFieldName: () => 'Picture',
mergeFieldValue: (_existing, newValue) => newValue,
getAnimatedImageLeadInSeconds: async () => 0,
@@ -132,7 +133,7 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
noteIds.map((noteId) => ({
noteId,
fields: {
ExpressionAudio: { value: '' },
SentenceAudio: { value: '' },
Picture: { value: '' },
},
})),
@@ -143,16 +144,13 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
storedMedia.push(filename);
},
},
getConfig: () =>
({ media: {}, fields: { audio: 'ExpressionAudio', image: 'Picture' } }) as AnkiConnectConfig,
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
preferredNames.find((name) => name && name in noteInfo.fields) ?? null,
getConfig: () => ({ media: {}, fields: { image: 'Picture' } }) as AnkiConnectConfig,
});
const queue = new PendingYoutubeMediaQueue(deps);
const queued = await queue.queueFromNote({
noteId: 42,
noteInfo: { noteId: 42, fields: { ExpressionAudio: { value: '' } } },
noteInfo: { noteId: 42, fields: {} },
label: 'demo',
});
await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv');
@@ -160,8 +158,7 @@ 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.ExpressionAudio ?? '', /^\[sound:audio\.mp3\]$/);
assert.equal('SentenceAudio' in (updatedNotes[0]?.fields ?? {}), false);
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio\.mp3\]$/);
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image\.webp">$/);
});
@@ -39,6 +39,7 @@ export interface PendingYoutubeMediaQueueDeps {
startTime: number;
endTime: number;
};
getResolvedSentenceAudioFieldName: (noteInfo: PendingYoutubeMediaNoteInfo) => string | null;
resolveConfiguredFieldName: (
noteInfo: PendingYoutubeMediaNoteInfo,
...preferredNames: (string | undefined)[]
@@ -135,7 +136,7 @@ export class PendingYoutubeMediaQueue {
startTime: mediaRange.startTime,
endTime: mediaRange.endTime,
label: job.label,
audioFieldName: this.resolveConfiguredAudioFieldName(job.noteInfo) ?? undefined,
audioFieldName: this.deps.getResolvedSentenceAudioFieldName(job.noteInfo) ?? undefined,
imageFieldName:
this.deps.resolveConfiguredFieldName(
job.noteInfo,
@@ -246,14 +247,6 @@ 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,
@@ -290,7 +283,7 @@ export class PendingYoutubeMediaQueue {
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
const audioField =
job.audioFieldName || this.resolveConfiguredAudioFieldName(noteInfo) || null;
job.audioFieldName || this.deps.getResolvedSentenceAudioFieldName(noteInfo) || null;
if (audioField) {
const existingAudio = noteInfo.fields[audioField]?.value || '';
mediaFields[audioField] = this.deps.mergeFieldValue(
-2
View File
@@ -65,7 +65,6 @@ 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>;
@@ -167,7 +166,6 @@ export function registerAnkiJimakuIpcRuntime(
options.getCachedMediaPath,
options.shouldRequireRemoteMediaCache,
options.getYoutubeMediaSourceUrl,
options.dismissOverlayNotification,
);
integration.start();
options.setAnkiIntegration(integration);
-15
View File
@@ -218,18 +218,3 @@ 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',
);
});
-4
View File
@@ -153,10 +153,6 @@ 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;
@@ -21,7 +21,6 @@ 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>;
@@ -75,7 +74,6 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
args.getCachedMediaPath,
args.shouldRequireRemoteMediaCache,
args.getYoutubeMediaSourceUrl,
args.dismissOverlayNotification,
);
}
@@ -139,7 +137,6 @@ 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>;
@@ -180,7 +177,6 @@ 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>;
@@ -223,7 +219,6 @@ 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 } : {}),
+17 -440
View File
@@ -556,29 +556,6 @@ test('parseSubtitleCues recovers a full Dialogue line surrounding generated frag
]);
});
test('parseSubtitleCues replaces animated glyph copies of a static canonical Dialogue line', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:00:01.00,0:00:04.00,OP - JP,,0,0,0,,重複字幕',
'Dialogue: 2,0:00:01.00,0:00:04.00,OP - JP,,0,0,0,,{\\pos(400,50)\\t(0,100,\\fry0)}重',
'Dialogue: 2,0:00:01.10,0:00:04.00,OP - JP,,0,0,0,,{\\pos(440,50)\\t(0,100,\\fry0)}複',
'Dialogue: 2,0:00:01.20,0:00:04.00,OP - JP,,0,0,0,,{\\pos(480,50)\\t(0,100,\\fry0)}字',
'Dialogue: 2,0:00:01.30,0:00:04.00,OP - JP,,0,0,0,,{\\pos(520,50)\\t(0,100,\\fry0)}幕',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{
startTime: 1,
endTime: 4,
text: '重複字幕',
source: 'canonical-ass',
animationStartTime: 1,
animationEndTime: 4,
},
]);
});
test('parseSubtitleCues does not promote a short animated fragment as a complete line', () => {
const content = [
'[Events]',
@@ -1273,147 +1250,6 @@ test('parseSubtitleCues drops symbol-font glyph decoration from a reconstructed
assert.equal(cues[0]?.text, 'sotto mimi ni ateru to');
});
test('parseSubtitleCues drops clipped repeated-glyph texture text', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
"Dialogue: 10,0:00:01.00,0:00:04.00,Default,,0,0,0,,I'm blocking them.",
'Dialogue: 2,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,80)\\fnSerangkaian Pattern Regular\\clip(800,20,1120,140)}LLLLLLLLLLLLLLLLLLLLLLLL',
'Dialogue: 3,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,150)\\fnSF Pro Display}Enter a message',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
["I'm blocking them.", 'Enter a message'],
);
});
test('parseSubtitleCues preserves opaque same-font text beside texture fragments', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 2,0:00:01.00,0:00:04.00,MarySigns,seed,0,0,0,,{\\pos(960,80)\\fnSerangkaian Pattern Regular\\clip(800,20,1120,140)}LLLLLLLLLLLLLLLLLLLLLLLL',
'Dialogue: 2,0:00:01.00,0:00:04.00,MarySigns,piece,0,0,0,,{\\pos(960,110)\\fnSerangkaian Pattern Regular\\clip(800,20,1120,140)}LLLL',
'Dialogue: 3,0:00:01.00,0:00:04.00,MarySigns,label,0,0,0,,{\\pos(960,150)\\fnSerangkaian Pattern Regular}Keep this label',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
['Keep this label'],
);
});
test('parseSubtitleCues drops tiny alpha payloads from a proven texture font', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 2,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain Medium\\clip(500,40,660,150)}LLLLLLLLLLLL',
"Dialogue: 1,0:00:06.00,0:00:09.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain\\fs10\\alpha&H70&}q26D'vrA;\\NE? GS\\NESLhlawEv",
'Dialogue: 3,0:00:06.00,0:00:09.00,FrogSigns,,0,0,0,,{\\pos(1040,620)\\fnSF Pro Display\\fs66}Waiting!',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
['Waiting!'],
);
});
test('parseSubtitleCues preserves a small multiline translation using an unverified transparent font', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 2,0:00:01.00,0:00:04.00,Transition,,0,0,0,,{\\pos(580,95)\\fnPhone UI\\fs60\\alpha&HF0&}Faded transition',
'Dialogue: 3,0:00:06.00,0:00:09.00,Phone,,0,0,0,,{\\pos(1040,620)\\fnPhone UI\\fs10\\alpha&H70&}Call me when you arrive.\\NI will still be awake.\\NDo not rush.',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
['Faded transition', 'Call me when you arrive.\nI will still be awake.\nDo not rush.'],
);
});
test('parseSubtitleCues drops clipped repeated-glyph texture text without a font override', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\an7\\pos(736.49,152.99)\\fscy150\\fs10\\bord3\\c&H657BC8&\\3c&H657BC8&\\blur3\\clip}lllllllllllll',
'Dialogue: 0,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\an7\\pos(769.9,106.18)\\fscy150\\fs12\\bord3\\c&H66729F&\\3c&H66729F&\\blur5\\clip}llll',
'Dialogue: 5,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(893,311)}Read',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
['Read'],
);
});
test('parseSubtitleCues drops per-character alpha texture text', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
"Dialogue: 10,0:00:01.00,0:00:04.00,Default,Girl,0,0,0,,So Doloris was actually Uika-chan from sumimi! That's amazing!",
"Dialogue: 2,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,240)\\fnCinzel}Hanasakigawa Girl's School",
'Dialogue: 3,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,300)\\fnSplit splat splodge\\clip(800,200,1120,400)}d{\\2a1}s{\\2a0}h{\\2a1}f{\\2a0}k{\\2a1}h{\\2a0}f{\\2a1}s{\\2a0}d{\\2a1}f{\\2a0}e',
'Dialogue: 3,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(980,340)\\fnSplit splat splodge}f {\\2a1}a',
'Dialogue: 4,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,360)\\fnGrain SemiBold}5{\\2a1}X{\\2a0}N{\\2a1}T{\\2a0}f{\\2a1}I{\\2a0}g{\\2a1}F{\\2a0}B{\\2a1}?{\\2a0}k{\\2a1}u{\\2a0}C{\\2a1}m',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
[
"So Doloris was actually Uika-chan from sumimi! That's amazing!",
"Hanasakigawa Girl's School",
],
);
});
test('parseSubtitleCues drops transparent texture payloads across an animated sign', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
"Dialogue: 90,0:00:01.00,0:00:04.00,Alt,,0,0,0,,Even if you want to see her, she doesn't want to see you!",
'Dialogue: 0,0:00:01.00,0:00:01.08,FrogSigns,,0,0,0,,{\\pos(699,803)\\fnSerangkaian Pattern Regular\\clip(300,380,1130,1050)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a0}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\\\\\\\\\\\\\\\\\\\\\',
'Dialogue: 3,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnGrain\\alpha&HE0&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
'Dialogue: 5,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnRoboto Medium\\alpha&H00&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
'Dialogue: 6,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnGrain\\alpha&HE0&}H1.4igcAhGYHVWD"kHcVlG2W9eKEWj"!X\\N\'uNVaEVpTXMd9rk7dnRX\'P!RhsS"Wn90k6',
'Dialogue: 6,0:00:01.00,0:00:01.08,FrogSigns,18K,0,0,0,,{\\pos(284,821)\\fnGrain\\alpha&HE0&}ou:QepiiPqQ.4n.IYbFaGHtPzWyKI9CUSq:',
'Dialogue: 1,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(581,921)\\fnSerangkaian Pattern Regular\\clip(195,495,986,1120)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{',
'Dialogue: 3,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnGrain\\alpha&HE0&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
'Dialogue: 5,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnRoboto Medium\\alpha&H00&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
'Dialogue: 3,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnGrain\\alpha&HF0&}9LF\'GpPCTlOkLxBLV:QN,8R8NUVM"ha.s\\NNUUPNTBdJih4jUthK34i,yYe;9EBgLXbET',
"Dialogue: 6,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(150,936)\\fnGrain\\alpha&HE0&}JS7vl:lD;'PzkCb!bGT;.7TbA.KCkEH0LOk",
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
[
'Street performance by Mortis from\nMujica - Acting prodigy in action!',
"Even if you want to see her, she doesn't want to see you!",
'Street performance by Mortis from\nMujica - Acting prodigy in action!',
],
);
});
test('parseSubtitleCues does not reconstruct short texture pieces under another actor', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,bubble,0,0,0,,{\\pos(245,-102)\\fnSerangkaian Pattern Regular\\clip(224,-1,831,106)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L',
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(917,293)\\alpha&H20&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(911,293)\\alpha&H58&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(845,300)\\alpha&H00&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(907,293)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,289,1010,338)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(911,293)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,289,1010,338)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(922,130)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,120,1010,173)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 5,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(893,311)\\fnSFProDisplay-Regular-STR}Read 3',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
['Read 3'],
);
});
test('parseSubtitleCues separates overlapping positioned English lyric sequences', () => {
const fragments = [
['my', 642, '0:00:01.00', '0:00:04.05'],
@@ -1442,7 +1278,7 @@ const eventsHeader = [
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
];
test('parseSubtitleCues keeps tall CC-style base dialogue publishable after removing furigana', () => {
test('parseSubtitleCues keeps a tall CC-style dialogue block publishable, not a fragment grid', () => {
const content = [
...eventsHeader,
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(212,383)\\fscx50\\fscy50}たき',
@@ -1451,94 +1287,9 @@ test('parseSubtitleCues keeps tall CC-style base dialogue publishable after remo
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(192,497)}お前…{\\fscx50} {\\fscx100}燈をバンドに誘ったの?',
].join('\n');
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 ?? []),
[],
);
const cue = parseSubtitleCues(content, 'test.ass')[0];
assert.equal(cue?.text, 'たき(立希)ともりお前… 燈をバンドに誘ったの?');
assert.equal(cue?.assLayout?.kind, 'positioned');
});
test('parseSubtitleCues marks re-shown countdown frames as a fragment grid', () => {
@@ -1596,25 +1347,25 @@ test('parseSubtitleCues marks a repeated-token sign wall as a fragment grid', ()
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.assLayout?.kind, 'fragment-grid');
});
test('parseSubtitleCues keeps a wrapped lyric with a staggered repeated token publishable', () => {
test('parseSubtitleCues keeps a wrapped lyric with repeated syllables publishable', () => {
const fragments = [
['dreams', 300, 115, '0:00:01.00'],
['ju', 250, 39, '0:00:01.00'],
['n', 280, 39, '0:00:01.00'],
['jo', 300, 39, '0:00:01.00'],
['u', 330, 39, '0:00:01.00'],
['to', 360, 39, '0:00:01.00'],
['jo', 395, 39, '0:00:01.02'],
['u', 425, 39, '0:00:01.00'],
['ne', 455, 39, '0:00:01.00'],
['tsu!', 485, 39, '0:00:01.00'],
['dreams', 300, 115],
['ju', 250, 39],
['n', 280, 39],
['jo', 300, 39],
['u', 330, 39],
['to', 360, 39],
['jo', 395, 39],
['u', 425, 39],
['ne', 455, 39],
['tsu!', 485, 39],
] as const;
const content = [
...eventsHeader,
...[0, 1].flatMap((layer) =>
fragments.map(
([text, x, y, start], index) =>
`Dialogue: ${layer},${start},0:00:04.00,ED Romaji,,0,0,0,,{\\pos(${x},${y})\\t(${index * 2},${index * 2 + 100},\\fscx120)}${text}`,
([text, x, y], index) =>
`Dialogue: ${layer},0:00:01.00,0:00:04.00,ED Romaji,,0,0,0,,{\\pos(${x},${y})\\t(${index * 2},${index * 2 + 100},\\fscx120)}${text}`,
),
),
].join('\n');
@@ -1928,177 +1679,3 @@ test('parseSubtitleCues splits chunked words whose gap only the excess rule catc
'Choices presumably made by chance',
);
});
test('parseSubtitleCues rebuilds a line from per-glyph phase stacks with staggered timing', () => {
// Each glyph lives as four events anchored at one point: a transparent pre-echo until
// its syllable is sung, a short highlight, a rising exit ghost, and a steady hold.
// No timing window is shared across the phases, only the anchor ties them together.
const glyphs = [
['エ', 559],
['ネ', 601],
['ル', 643],
['ギ', 686],
['ー', 728],
] as const;
const timestamp = (seconds: number) => `0:00:${seconds.toFixed(2).padStart(5, '0')}`;
const content = [
...eventsHeader,
...glyphs.flatMap(([glyph, x], index) => {
const highlightStart = 20.9 + index * 0.4;
return [
`Dialogue: 3,${timestamp(20 + index * 0.03)},${timestamp(highlightStart)},OP - JP,,0,0,0,,{\\blur1.5\\bord2\\c&H404040&\\3c&HFFFFFF&\\an5\\pos(${x},50)\\fad(300,0)\\1a&HFF&}${glyph}`,
`Dialogue: 3,${timestamp(highlightStart)},${timestamp(highlightStart + 0.4)},OP - JP,,0,0,0,,{\\an5\\pos(${x},50)\\bord2\\c&H404040&\\3c&HFFFFFF&\\t(120,240,\\3c&H007A7A7A&\\blur0)\\fad(0,300)}${glyph}`,
`Dialogue: 3,${timestamp(highlightStart)},${timestamp(highlightStart + 2.4)},OP - JP,,0,0,0,,{\\an5\\move(${x},50,${x},0)\\bord0\\shad0\\t(\\c&HFFFFFF&\\blur5\\alpha&HFF&)}${glyph}`,
`Dialogue: 3,${timestamp(highlightStart + 0.4)},${timestamp(26.1 + index * 0.05)},OP - JP,,0,0,0,,{\\an5\\pos(${x},50)\\bord2\\c&H404040&\\3c&HFFFFFF&\\fad(0,300)}${glyph}`,
];
}),
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 1);
assert.equal(cues[0]?.text, 'エネルギー');
assert.equal(cues[0]?.source, 'reconstructed-ass');
// Published from the first sung syllable to the end of the hold, so the transparent
// lead-in and the exit ghosts' fade tail never overlap the neighboring lines. The
// full generated span stays available as the animation window.
assert.equal(cues[0]?.startTime, 20.9);
assert.equal(cues[0]?.endTime, 26.3);
assert.equal(cues[0]?.animationStartTime, 20);
assert.equal(cues[0]?.animationEndTime, 26.3);
});
test('parseSubtitleCues drops transparent glow echoes and merges an exit replay', () => {
// The visible line sits at one row while transparent-fill glow copies duplicate every
// glyph on another row, and the exit shatters each glyph into copies launched from a
// shared anchor. Only the authored line may publish, as a single unbroken cue.
const glyphs = [
['さ', 686],
['あ', 728],
['預', 770],
['け', 812],
['て', 854],
] as const;
const content = [
...eventsHeader,
...glyphs.flatMap(([glyph, x]) => [
`Dialogue: 3,0:00:16.28,0:00:18.71,OP - JP,,0,0,0,,{\\an2\\pos(${x},85)\\fad(200,0)\\fry-90\\c&H404040&\\3c&HF4F4F4&\\bord2\\t(0,300,\\fry0)}${glyph}`,
...[0, 1].map(
() =>
`Dialogue: 3,0:00:16.28,0:00:20.01,OP - JP,,0,0,0,,{\\pos(${x},15)\\blur5.8\\fry-90\\1a&HFF&\\fad(200,0)\\3c&H3F26AA&\\t(0,300,\\fry0)\\t(2596,3222,\\bord0\\3a&HFF&)}${glyph}`,
),
...[0, 1].map(
(copy) =>
`Dialogue: 3,0:00:18.71,0:00:20.89,OP - JP,,0,0,0,,{\\an5\\bord2\\fad(0,200)\\move(${x},50,${x + 25 + copy * 3},${17 - copy * 39},1605,2055)\\t(450,792,\\c&H3500DE&\\bord0)\\t(1605,2055,\\blur15\\fscx20\\fscy20\\1a&H50&\\3a&H50&)}${glyph}`,
),
]),
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 1);
assert.equal(cues[0]?.text, 'さあ預けて');
assert.equal(cues[0]?.startTime, 16.28);
assert.equal(cues[0]?.endTime, 20.89);
});
test('parseSubtitleCues does not double a line rendered whole beside its glyph swarm', () => {
// An assembly effect shows the authored line as one positioned event while dozens of
// per-glyph particle copies converge onto each glyph's anchor. The whole event and
// the swarm spell the same text and must publish as one line, once.
const glyphs = [
['可', 854],
['笑', 896],
['し', 938],
['い', 980],
['わ', 1022],
['ね', 1064],
] as const;
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}`,
...glyphs.flatMap(([glyph, x], index) =>
[0, 1, 2].map(
(copy) =>
`Dialogue: 2,0:00:17.${30 + index * 5 + copy},0:00:19.10,OP - JP,,0,0,0,,{\\bord4\\blur4\\an5\\fad(500,0)\\move(${x - 60 - copy * 17},${120 + copy * 6},${x},50,20,900)\\clip(${x - 70},80,${x - 66},84)\\t(20,900,\\clip(${x - 4},7,${x},11))}${glyph}`,
),
),
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 1);
assert.equal(cues[0]?.text, '可笑しいわね');
});
test('parseSubtitleCues drops a wall of near-invisible positioned texture strings', () => {
// An image drawn by \p1 vector events carries no texture seed, but its glyph payload
// is still dozens of near-transparent positioned strings sharing one window. A real
// 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...",
...Array.from(
{ length: 12 },
(_, index) =>
`Dialogue: 9,0:00:12.66,0:00:14.91,MarySigns,,0,0,0,,{\\an7\\pos(${640 + index * 13},${4 + index * 40})\\fnGrain SemiBold\\c&H000000&\\alpha&HFD&}gtO${index}x!`,
),
'Dialogue: 9,0:00:12.66,0:00:14.91,OtherSign,,0,0,0,,{\\pos(151,769)\\fnGrain\\alpha&HE0&}A faint but real translation',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
["We'll play as a band, and then...", 'A faint but real translation'],
);
});
test('parseSubtitleCues drops zero-scaled zero-clipped hidden warning text', () => {
const content = [
...eventsHeader,
'Dialogue: 99,0:00:00.00,0:00:15.16,Default,,0,0,0,,{\\org(0,0)\\fscx0\\fscy0\\clip(0,0,0,0)}Your media player does not support the subtitle format.',
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\fscx0\\t(0,300,\\fscx100)}見えるセリフ',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
['見えるセリフ'],
);
});
test('parseSubtitleCues keeps hidden events hidden when a transform animates an unrelated tag', () => {
// `\t(...)` only reveals a zero-scaled or fully clipped event when it animates the
// scale or the clip itself. Animating an unrelated property -- at any nesting depth --
// leaves the event invisible, so its text must not reach the subtitles.
const content = [
...eventsHeader,
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\fscx0\\fscy0\\clip(0,0,0,0)\\t(0,300,\\bord5)}hidden warning',
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\clip(0,0,0,0)\\t(0,600,\\t(0,300,\\blur4))}nested hidden warning',
'Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,{\\fscx0\\t(0,300,\\fscx100)}grows into view',
'Dialogue: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,{\\clip(0,0,0,0)\\t(0,300,\\clip(0,0,500,500))}wipes into view',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
['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']);
});
File diff suppressed because it is too large Load Diff
@@ -134,7 +134,7 @@ export function createSubtitleProcessingController(
try {
const cachedTokenized = getCachedTokenization(text);
if (cachedTokenized) {
output = { ...cachedTokenized, text };
output = cachedTokenized;
} 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, text };
return cached;
},
hasCachedSubtitle: (text: string) => {
const cacheKey = normalizeSubtitleCacheKey(text);
-17
View File
@@ -84,17 +84,6 @@ 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(
'猫です',
@@ -1693,12 +1682,6 @@ 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(
+1 -9
View File
@@ -887,15 +887,7 @@ export async function tokenizeSubtitle(
text: string,
deps: TokenizerServiceDeps,
): Promise<SubtitleData> {
// 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');
const displayText = normalizePlainSubtitleText(text);
// 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.
-112
View File
@@ -39,118 +39,6 @@ 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(
[
+12 -228
View File
@@ -2,31 +2,9 @@ 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 (
@@ -61,129 +39,27 @@ function parseAttributeMap(raw: string): Map<string, string> {
return attrs;
}
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 {
function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] {
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(durationMs)) {
if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) {
continue;
}
const rawInner = match[2] ?? '';
const inner = rawInner.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '');
const inner = (match[2] ?? '').replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '');
const text = decodeHtmlEntities(inner).trim();
if (!text) {
continue;
}
rows.push({
startMs,
durationMs,
text,
isGenerated: /<s\b/.test(rawInner),
rollingWindow: resolveRollingWindow(attrs, windowDefinitions),
});
rows.push({ startMs, durationMs, text });
}
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);
return rows;
}
function formatVttTimestamp(ms: number): string {
@@ -195,79 +71,6 @@ 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;
@@ -276,7 +79,7 @@ export function isYoutubeTimedTextExtension(value: string | undefined): boolean
}
export function convertYoutubeTimedTextToVtt(xml: string): string {
const { rows, eventStartsMs, hasRollingWindowEvents } = extractYoutubeTimedTextDocument(xml);
const rows = extractYoutubeTimedTextRows(xml);
if (rows.length === 0) {
return 'WEBVTT\n';
}
@@ -287,19 +90,10 @@ 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 =
nextEventStart !== undefined
? nextEventStart
: nextRow && unclampedEnd > nextRow.startMs
? Math.max(row.startMs, nextRow.startMs - 1)
: unclampedEnd;
nextRow && unclampedEnd > nextRow.startMs
? Math.max(row.startMs, nextRow.startMs - 1)
: unclampedEnd;
if (clampedEnd <= row.startMs) {
continue;
}
@@ -312,19 +106,9 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
if (!text) {
continue;
}
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}`,
);
}
blocks.push(
`${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${text}`,
);
}
return `WEBVTT\n\n${blocks.join('\n\n')}\n`;
-4
View File
@@ -5925,8 +5925,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
showDesktopNotification,
showOverlayNotification: (payload) =>
overlayNotificationsRuntime.showOverlayNotification(payload),
dismissOverlayNotification: (id) =>
overlayNotificationsRuntime.dismissOverlayNotification(id),
createFieldGroupingCallback: () => createFieldGroupingCallback(),
broadcastRuntimeOptionsChanged: () =>
overlayVisibilityComposer.broadcastRuntimeOptionsChanged(),
@@ -6417,8 +6415,6 @@ 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) =>
-2
View File
@@ -132,7 +132,6 @@ 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'];
@@ -335,7 +334,6 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
: {}),
showDesktopNotification: params.showDesktopNotification,
showOverlayNotification: params.showOverlayNotification,
dismissOverlayNotification: params.dismissOverlayNotification,
createFieldGroupingCallback: params.createFieldGroupingCallback,
broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged,
getFieldGroupingResolver: params.getFieldGroupingResolver,
@@ -26,7 +26,6 @@ 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,8 +33,6 @@ 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,
@@ -59,8 +57,6 @@ 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: () => {},
@@ -77,8 +73,6 @@ 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,7 +39,6 @@ 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'];
@@ -79,7 +78,6 @@ 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,8 +22,6 @@ 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,
@@ -49,8 +47,6 @@ 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',
@@ -62,7 +58,5 @@ test('build initialize overlay runtime options maps dependencies', () => {
'set-tracker',
'set-anki',
'notify',
'show-overlay',
'dismiss-overlay',
]);
});
@@ -33,7 +33,6 @@ 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>;
@@ -74,7 +73,6 @@ 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>;
@@ -109,7 +107,6 @@ 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 } : {}),
+3 -180
View File
@@ -60,33 +60,7 @@ test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () =
{ startTime: 1, endTime: 3, text: '二行目' },
],
}),
'一行目\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大丈夫。',
'一行目\n二行目',
);
});
@@ -221,7 +195,7 @@ test('resolvePrimarySubtitleText combines parsed dialogue with a reconstructed l
],
});
assert.equal(text, '普通のセリフ\n\n今 手にある');
assert.equal(text, '普通のセリフ\n今 手にある');
});
test('resolvePrimarySubtitleText uses fragment grids only to account for live sign pieces', () => {
@@ -326,32 +300,7 @@ test('resolvePrimarySubtitleText combines simultaneous canonical cues in source
],
});
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');
assert.equal(text, 'first\nsecond');
});
test('resolvePrimarySubtitleText collapses whitespace variants of a canonical lyric', () => {
@@ -548,129 +497,3 @@ 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
// must explain the ghost fragments without re-surfacing next to the active line.
const cues = [
{
startTime: 1.0,
endTime: 6.0,
text: 'エネルギーはサイクル',
source: 'reconstructed-ass' as const,
animationStartTime: 0.5,
animationEndTime: 7.0,
assStyle: 'OP - JP',
},
{ startTime: 6.0, endTime: 12.0, text: '象徴的なパレード' },
];
assert.equal(
resolvePrimarySubtitleText({
liveText: 'エ\nネ\nル\nギ\nー\n象徴的なパレード',
currentTimeSec: 6.5,
cues,
}),
'象徴的なパレード',
);
});
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',
);
});
+18 -65
View File
@@ -1,4 +1,4 @@
import type { AssVerticalBand, SubtitleCue } from '../../types';
import type { SubtitleCue } from '../../types';
import {
removeAssControlDebrisLines,
removeLiveGlyphFragmentLines,
@@ -57,52 +57,21 @@ 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 uniqueCueTextGroups(cues: readonly SubtitleCue[]): string[] {
const groups: string[] = [];
function uniqueCueTexts(cues: readonly SubtitleCue[]): string[] {
const texts: 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);
lines.push(line);
}
if (lines.length > 0) {
groups.push(lines.join('\n'));
texts.push(line);
}
}
return groups;
return texts;
}
function compactLineSegments(text: string): string[] {
@@ -149,15 +118,12 @@ function resolveActiveParsedPrimarySubtitle(options: {
return null;
}
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 })),
),
];
});
const parsedSegments = selected.flatMap((cue) =>
compactLineSegments(cue.text).map((segment) => ({
segment,
recovered: cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass',
})),
);
if (
!liveSegments.every((liveSegment) =>
parsedSegments.some(({ segment, recovered }) =>
@@ -168,27 +134,14 @@ function resolveActiveParsedPrimarySubtitle(options: {
return null;
}
// 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 groups = uniqueCueTextGroups(
orderCuesForDisplay(displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid')),
);
const texts = uniqueCueTexts(selected.filter((cue) => cue.assLayout?.kind !== 'fragment-grid'));
return {
text: groups.join(CUE_BOUNDARY),
startTime: Math.min(...displayCues.map((cue) => cue.startTime)),
endTime: Math.max(...displayCues.map((cue) => cue.endTime)),
cues: displayCues,
text: texts.join('\n'),
startTime: Math.min(...selected.map((cue) => cue.startTime)),
endTime: Math.max(...selected.map((cue) => cue.endTime)),
cues: selected,
};
}
@@ -252,9 +205,9 @@ export function resolveCanonicalPrimarySubtitle(options: {
return null;
}
const groups = uniqueCueTextGroups(orderCuesForDisplay(selected));
const texts = uniqueCueTexts(selected);
return {
text: groups.join(CUE_BOUNDARY),
text: texts.join('\n'),
startTime: Math.min(...selected.map((cue) => cue.startTime)),
endTime: Math.max(...selected.map((cue) => cue.endTime)),
cues: selected,
@@ -98,22 +98,6 @@ test('parsed secondary text drops a reconstructed grid of positioned sign fragme
);
});
test('parsed secondary text keeps phone translations while dropping texture payloads', () => {
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 2,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain Medium\\clip(500,40,660,150)}LLLLLLLLLLLL',
'Dialogue: 90,0:00:01.00,0:00:03.00,Default,,0,0,0,,Why did you choose Hanajo instead?',
"Dialogue: 1,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain\\fs10\\alpha&H70&}q26D'vrA;\\NE? GS\\NESLhlawEv",
"Dialogue: 3,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(582,180)\\fnSF Pro Display\\fs66}We're {\\2a0}running {\\2a1}out {\\2a0}of {\\2a1}time!\\N{\\2a0}Where {\\2a1}are {\\2a0}you {\\2a1}right {\\2a0}now?!",
].join('\n');
assert.equal(
findActiveSubtitleText(parseSubtitleCues(ass, 'phone.ass'), 2),
"Why did you choose Hanajo instead?\nWe're running out of time!\nWhere are you right now?!",
);
});
test('parsed secondary lyrics keep explicit ASS vertical order when durations alternate', () => {
const lyric = (options: { start: string; end: string; style: string; y: number; text: string }) =>
`Dialogue: 0,0:00:${options.start},0:00:${options.end},${options.style},,0,0,0,fx,{\\move(100,${options.y},120,${options.y})\\t(0,200,\\fscx110)}${options.text}\\N{\\p1}m 0 0 l 0 5`;
@@ -186,30 +170,6 @@ test('findActiveSubtitleText keeps a canonical ASS cue for its generated animati
assert.equal(findActiveSubtitleText([poof], 1111.59), '');
});
test('findActiveSubtitleText advances when the next canonical lyric animation starts', () => {
const cues = [
{
startTime: 121.73,
endTime: 124.1,
text: 'Torn at the seams, a sound pours out',
source: 'canonical-ass' as const,
animationStartTime: 121.4,
animationEndTime: 124.1,
},
{
startTime: 124.13,
endTime: 126.38,
text: 'Its silent, yet spreads all around',
source: 'canonical-ass' as const,
animationStartTime: 123.8,
animationEndTime: 126.38,
},
];
assert.equal(findActiveSubtitleText(cues, 123.79), cues[0]!.text);
assert.equal(findActiveSubtitleText(cues, 123.8), cues[1]!.text);
});
test('ASS fragment karaoke stays separated by style with authored word spacing', () => {
const lineEvents = (
style: string,
+1 -20
View File
@@ -91,26 +91,7 @@ export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds
(cue) =>
cue.source === 'canonical-ass' && cue.startTime <= timeSeconds && cue.endTime > timeSeconds,
);
const enteringCanonical = cues.filter(
(cue) =>
cue.source === 'canonical-ass' &&
(cue.animationStartTime ?? cue.startTime) <= timeSeconds &&
cue.startTime > timeSeconds &&
(cue.animationEndTime ?? cue.endTime) > timeSeconds,
);
const nextAuthoredStart = enteringCanonical.reduce(
(earliest, cue) => Math.min(earliest, cue.startTime),
Infinity,
);
// Generated lyrics can begin drawing before their canonical Comment timing. Once that
// entrance starts, replace a preceding lyric that ends before the new authored span;
// genuinely concurrent subtitles that continue through the new span stay selected.
const selectedCanonical = new Set<SubtitleCue>([
...authoredCanonical.filter(
(cue) => enteringCanonical.length === 0 || cue.endTime > nextAuthoredStart,
),
...enteringCanonical,
]);
const selectedCanonical = new Set<SubtitleCue>(authoredCanonical);
if (selectedCanonical.size === 0) {
const animatedCanonical = cues.filter(
(cue) =>
-29
View File
@@ -11,7 +11,6 @@ import {
getFrequencyRankLabelForToken,
getJlptLevelLabelForToken,
normalizeSubtitle,
normalizeSubtitleForDisplay,
prepareSecondarySubtitleLines,
sanitizeSubtitleHoverTokenColor,
shouldRenderTokenizedSubtitle,
@@ -1005,34 +1004,6 @@ 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.
+7 -19
View File
@@ -50,21 +50,6 @@ 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]+)$/;
@@ -429,13 +414,16 @@ function renderWithTokens(
const fragment = document.createDocumentFragment();
if (sourceText) {
const normalizedSource = normalizeSubtitleForDisplay(sourceText, preserveLineBreaks);
const normalizedSource = normalizeSubtitle(sourceText, true, !preserveLineBreaks);
const segments = alignTokensToSourceText(tokens, normalizedSource);
for (const segment of segments) {
if (segment.kind === 'text') {
// Normalization already resolved which breaks survive; every one left is real.
renderPlainTextPreserveLineBreaks(fragment, segment.text);
if (preserveLineBreaks) {
renderPlainTextPreserveLineBreaks(fragment, segment.text);
} else {
fragment.appendChild(document.createTextNode(segment.text));
}
continue;
}
@@ -760,7 +748,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
return;
}
const normalized = normalizeSubtitleForDisplay(text, ctx.state.preserveSubtitleLineBreaks);
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
const hasRenderableTokens =
shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && Boolean(tokens);
if (
+2 -2
View File
@@ -1,4 +1,4 @@
import type { AssVerticalBand, SubtitleCue } from '../core/services/subtitle-cue-parser';
import type { 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 { AssVerticalBand, SubtitleCue };
export type { SubtitleCue };
export type SubtitleSidebarLayout = 'overlay' | 'embedded';