Compare commits

..

13 Commits

Author SHA1 Message Date
sudacode fb8d5bf7ba test(subtitles): verify \t nesting cap and document dedup sort order
- Assert collectAssOverrideCommands stops at the recursion cap (exact command count) instead of asserting only the outer tag, using a realistic nesting depth instead of 200k
- Document mergeDuplicateCues' precondition that cues are pre-sorted by startTime/endTime/order, since burst detection silently breaks on unsorted input
2026-08-05 22:56:40 -07:00
sudacode c305bf34c2 fix(subtitles): cap ASS \t nesting depth to prevent stack overflow
- guard override-block recursion in parseOverrideBlock at 8 levels; pathologically nested \t(...) tags no longer blow the call stack
- extract duplicate/animation-burst cue collapsing out of subtitle-cue-parser.ts into subtitle-cue-dedup.ts
- add regression tests for deep \t nesting, SRT/VTT brace stripping, and ASS content sniffed behind a lying .srt extension
2026-08-05 00:30:05 -07:00
sudacode 64534299ed fix(subtitles): collapse duplicate ASS events and decode text once
- Drop vector drawing runs (\p1...\p0) and collapse duplicate/animation-burst cue events (karaoke frames, layered shadow copies) so typeset ASS scripts stop flooding the subtitle sidebar
- Add ass-text.ts to decode ASS text once at ingestion, mirroring mpv's ass_to_plaintext (\N/\n/\h handling, unclosed braces, no \{ \} \\ escapes)
- Renderer, timing tracker, tokenizer, and tokenization cache now consume the already-decoded plain text instead of each re-deriving it, fixing mismatched cache keys for the same authored line
2026-08-04 22:59:42 -07:00
sudacode fe4dacc1e7 fix(overlay): show plain subtitle line immediately on tokenization cache miss (#184) 2026-08-04 01:55:52 -07:00
sudacode b08cd0db35 fix(streaming): keep subtitle tokenization prefetch warm for full episodes (#183) 2026-08-03 21:22:18 -07:00
sudacode bffb1c5982 fix(logging): surface subtitle processing debug/warn logs (#182) 2026-08-03 20:44:39 -07:00
sudacode 5b8848518a feat(subsync): add reference and target subtitle track picker (#181) 2026-08-03 01:00:14 -07:00
sudacode 176edd67f1 chore(release): v0.19.1 2026-08-01 23:59:20 -07:00
sudacode 4d65dec340 fix(youtube): prevent playlist URLs from stalling yt-dlp probes (#180) 2026-08-01 22:56:28 -07:00
sudacode 6607c333bc fix(overlay): strip spinner frame from subsync overlay card
- Add overlayBody override to ConfiguredStatusNotificationOptions so overlay/OSD/desktop can diverge
- Extract getSubsyncStatusNotificationOptions() to strip the ASCII spinner frame from the overlay card (OSD keeps it since it renders the raw spinner)
- Add tests for spinner stripping and subsync result notifications
2026-07-31 18:03:26 -07:00
sudacode b2bbf1ae12 chore: regenerate config example artifacts 2026-07-31 17:49:02 -07:00
sudacode b204d4dd6e feat(anki): add configurable word card type for Kiku/Lapis (#175) 2026-07-31 17:17:29 -07:00
sudacode 89ed675935 fix(overlay): keep Yomitan popup interactive on macOS/Windows (#177) 2026-07-30 19:47:08 -07:00
107 changed files with 4251 additions and 695 deletions
+9
View File
@@ -1,5 +1,14 @@
# Changelog # Changelog
## v0.19.1 (2026-08-01)
### Added
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
### Fixed
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
## v0.19.0 (2026-07-29) ## v0.19.0 (2026-07-29)
### Added ### Added
+7 -9
View File
@@ -26,7 +26,7 @@
"eslint": "^10.8.0", "eslint": "^10.8.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"undici": "7.28.0", "undici": "7.29.0",
}, },
}, },
}, },
@@ -36,8 +36,9 @@
"overrides": { "overrides": {
"@xmldom/xmldom": "0.8.13", "@xmldom/xmldom": "0.8.13",
"app-builder-lib": "26.15.3", "app-builder-lib": "26.15.3",
"brace-expansion": "5.0.8", "brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.15.3", "electron-builder-squirrel-windows": "26.15.3",
"fast-uri": "3.1.5",
"form-data": "4.0.6", "form-data": "4.0.6",
"ip-address": "10.2.0", "ip-address": "10.2.0",
"js-yaml": "4.3.0", "js-yaml": "4.3.0",
@@ -46,6 +47,7 @@
"picomatch": "4.0.4", "picomatch": "4.0.4",
"tar": "7.5.21", "tar": "7.5.21",
"tmp": "0.2.7", "tmp": "0.2.7",
"undici": "7.29.0",
}, },
"packages": { "packages": {
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], "@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
@@ -266,7 +268,7 @@
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="], "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
@@ -404,7 +406,7 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="], "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -714,7 +716,7 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -754,8 +756,6 @@
"@discordjs/rest/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="], "@discordjs/rest/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
"@discordjs/rest/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
"@discordjs/util/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="], "@discordjs/util/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
@@ -808,8 +808,6 @@
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"node-gyp/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
"node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], "node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], "pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: logging
- Background startup now respects the configured logging level when `--log-level` is not explicitly provided.
@@ -0,0 +1,4 @@
type: internal
area: dependencies
- Patched three high-severity dependency advisories flagged by `bun audit`: `undici` (cross-user information disclosure via degenerate private cache directives), `brace-expansion` (denial of service via unbounded intermediate arrays), and `fast-uri` (host confusion via backslash authority introducer).
@@ -0,0 +1,7 @@
type: fixed
area: streaming
- Jellyfin playback now seeds the subtitle tokenization prefetch straight from the subtitle file it downloads, instead of waiting on an mpv track-selection event that could be missed or coalesced and leave a whole episode tokenizing line by line.
- Streamed media no longer drops its parsed subtitle cues when the active subtitle track briefly cannot be resolved, such as when cycling onto a subtitle track embedded in the stream.
- Subtitle prefetching now runs to the end of a file instead of stopping as soon as the tokenization cache fills, so the back half of an episode no longer gets tokenized line by line during playback. Previously the cache was also never cleared between episodes, so the stall carried over to every later title in a session.
- Raised the tokenization cache from 256 to 2500 lines. It is now purely a memory bound rather than a limit on how much gets prefetched, and it leaves room for lines that repeat across episodes so openings and endings stay warm between titles.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: subtitles
- Heavily typeset ASS scripts (karaoke OP/ED, sign work) no longer fill the subtitle sidebar with garbage. Vector drawing runs (`\p1``\p0`) are no longer shown as subtitle text (e.g. `m 20 0 b 10 0 0 10 0 20 …`), and duplicate events in a parsed subtitle file collapse into one cue: identical text over an identical span (layered "shadow" copies), and per-frame animation bursts. An ASS burst has to prove itself with authoring evidence — a temporal tag (`\t`, `\move`, karaoke timing), an animated `Effect` column, or override values that change from event to event — plus one shared style and actor, so three rapid `えっ` reactions from three characters, or a sign repeated with the same static `\clip`, stay separate. SRT and VTT carry no such metadata, so there the run has to be at least five contiguous events all shorter than 0.1s, which is where ASS-to-SRT conversion leaves karaoke frames.
- Subtitle text is now decoded from ASS exactly once, where it enters the app, and matches how mpv renders the same line (including `\N`, `\n`, `\h`, unclosed `{`, and the fact that `\{` is not an escape). Renderer, timing tracker, tokenizer and the tokenization cache take that decoded text as-is instead of each re-deriving it, so one authored line can no longer produce two different cache keys, and a cue that normalizes to nothing is no longer stored as subtitle text or cached under an empty key.
@@ -0,0 +1,7 @@
type: changed
area: subsync
- The subsync modal now lets you pick both sides of an alass run: the reference subtitle (correct timing, defaults to the loaded secondary subtitle track) and the out-of-sync subtitle that gets retimed (defaults to the active primary track).
- alass can now use the loaded video file itself as the reference (audio-based, local files only). It is offered in the reference list but is never the default.
- The out-of-sync subtitle picker also applies to ffsubsync, so a track other than the active primary one can be retimed.
- Retiming the secondary subtitle track now reloads the synced result back into the secondary slot and leaves the primary track selected, instead of replacing the primary subtitle.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Subtitle lines no longer wait for tokenization to finish before appearing, even when the previous line is still being processed. On a tokenization cache miss, the plain line is shown immediately at its cue time and upgrades in place once tokens and annotations are ready; stale results cannot replace newer cues, and the basic plain-text websocket no longer receives a duplicate event for the annotation-only upgrade. A failed tokenization is no longer cached as the plain line, so a repeated line gets another chance at annotations instead of staying plain for the rest of the session.
+5 -2
View File
@@ -523,7 +523,7 @@
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
// Automatic Anki updates and media generation options. // Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running. // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart. // Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart. // Most other AnkiConnect settings still require restart.
// ========================================== // ==========================================
@@ -605,7 +605,10 @@
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false "enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false "deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
} // Is kiku setting. }, // Is kiku setting.
"lapisKiku": {
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
} // Lapis kiku setting.
}, // Automatic Anki updates and media generation options. }, // Automatic Anki updates and media generation options.
// ========================================== // ==========================================
+16 -1
View File
@@ -289,6 +289,21 @@ Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is c
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. 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)
Word cards get a card-type flag when SubMiner fills their sentence, whether that comes from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining. By default the flag is `IsWordAndSentenceCard`; pick a different one with `ankiConnect.lapisKiku.wordCardKind`.
```jsonc
"ankiConnect": {
"isKiku": { "enabled": true },
"lapisKiku": {
"wordCardKind": "click" // word-and-sentence (default), click, sentence, audio, none
}
}
```
`click` marks `IsClickCard`, `sentence` marks `IsSentenceCard`, `audio` marks `IsAudioCard`, and `none` leaves the flags untouched for templates that manage them elsewhere. Whichever flag is chosen, the other card-type flags are cleared so the note never claims two card types. The setting is only read when `isKiku` or `isLapis` is enabled, and cards mined with Mine Sentence or Mine Audio keep their own flag.
## Field Grouping (Kiku) ## Field Grouping (Kiku)
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields. When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields.
@@ -314,7 +329,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
### What Gets Merged ### What Gets Merged
| Field | Merge behavior | | Field | Merge behavior |
| -------- | ---------------------------------------- | | -------- | --------------------------------------------- |
| Sentence | Both cards' sentences kept as grouped entries | | Sentence | Both cards' sentences kept as grouped entries |
| Audio | Both cards' `[sound:...]` entries kept | | Audio | Both cards' `[sound:...]` entries kept |
| Image | Both cards' images kept | | Image | Both cards' images kept |
+9
View File
@@ -1,5 +1,14 @@
# Changelog # Changelog
## v0.19.1 (2026-08-01)
**Added**
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
**Fixed**
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
## v0.19.0 (2026-07-29) ## v0.19.0 (2026-07-29)
**Added** **Added**
+25 -7
View File
@@ -399,7 +399,7 @@ See `config.example.jsonc` for detailed configuration options.
``` ```
| Option | Values | Description | | Option | Values | Description |
| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) | | `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. | | `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. | | `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
@@ -556,7 +556,7 @@ Secondary subtitles do **not** auto-load by default. To turn them on for local a
``` ```
| Option | Values | Description | | Option | Values | Description |
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). | | `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) | | `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) | | `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
@@ -1069,6 +1069,9 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
"enabled": true, "enabled": true,
"fieldGrouping": "manual", "fieldGrouping": "manual",
"deleteDuplicateInAuto": true "deleteDuplicateInAuto": true
},
"lapisKiku": {
"wordCardKind": "word-and-sentence"
} }
} }
``` ```
@@ -1077,6 +1080,21 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits. - Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`. - When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes). - `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
- `lapisKiku.wordCardKind` picks the card-type flag set on word cards; see [Word Card Type](#word-card-type). It is read only while `isLapis` or `isKiku` is enabled.
### Word Card Type
When SubMiner fills the sentence on a mined word card - from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining - it marks which card that note should generate. `ankiConnect.lapisKiku.wordCardKind` chooses the flag:
| Value | Flag set |
| ----------------------------- | ----------------------- |
| `word-and-sentence` (default) | `IsWordAndSentenceCard` |
| `click` | `IsClickCard` |
| `sentence` | `IsSentenceCard` |
| `audio` | `IsAudioCard` |
| `none` | none; flags left as-is |
The other card-type flags are cleared so a note never claims two card types at once. Notes are skipped when the note type has no field for the chosen flag, and when the note was already mined as a sentence or audio card. Cards created by Mine Sentence and Mine Audio keep their own flag regardless of this setting.
### N+1 Word Highlighting ### N+1 Word Highlighting
@@ -1168,7 +1186,7 @@ TsukiHime subtitle search works out of the box and needs no account or API key.
``` ```
| Option | Values | Description | | Option | Values | Description |
| ---------------------------- | ------------ | ---------------------------------------------------------------------------------------------------- | | ---------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. | | `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) | | `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
@@ -1178,9 +1196,9 @@ See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, lang
### Subtitle Sync ### Subtitle Sync
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below). Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The picker lets you choose which track gets retimed (the active primary track by default) and, for alass, which reference it is aligned against (the secondary subtitle track by default). Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using a secondary subtitle as reference - [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using another subtitle as reference; it can also take the local video file as reference (alass extracts the audio itself)
- [`ffsubsync`](https://github.com/smacke/ffsubsync) - audio-based sync using the video file as reference - [`ffsubsync`](https://github.com/smacke/ffsubsync) - audio-based sync using the video file as reference
```json ```json
@@ -1229,7 +1247,7 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
``` ```
| Option | Values | Description | | Option | Values | Description |
| -------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- | | -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | | `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) | | `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) | | `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
@@ -1540,7 +1558,7 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) | | `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) | | `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) | | `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
+6 -3
View File
@@ -161,10 +161,13 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
1. Open the subsync modal from the overlay. 1. Open the subsync modal from the overlay.
2. Select the sync engine (alass or ffsubsync). 2. Select the sync engine (alass or ffsubsync).
3. For alass, select a reference subtitle track from the video. 3. For alass, pick the **reference** - the subtitle with correct timing. This defaults to the secondary subtitle track. The loaded video file can also be used as the reference (alass extracts the audio itself), but it is never the default.
4. SubMiner runs the sync and reloads the corrected subtitle. 4. Pick the **out-of-sync subtitle** - the track that gets retimed. This defaults to the active primary subtitle track and applies to both engines.
5. SubMiner runs the sync and reloads the corrected subtitle into the slot the out-of-sync track came from: retiming the secondary track keeps it secondary and leaves the primary track selected.
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs. The reference and the out-of-sync subtitle must be different tracks; the reference list hides whichever track is selected as the target.
For remote streams, including Jellyfin playback, the modal only offers alass with a subtitle reference. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync and the video-file reference need direct access to the local media file and are unavailable for stream URLs.
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running. When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
+5 -2
View File
@@ -523,7 +523,7 @@
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
// Automatic Anki updates and media generation options. // Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running. // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart. // Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart. // Most other AnkiConnect settings still require restart.
// ========================================== // ==========================================
@@ -605,7 +605,10 @@
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false "enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false "deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
} // Is kiku setting. }, // Is kiku setting.
"lapisKiku": {
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
} // Lapis kiku setting.
}, // Automatic Anki updates and media generation options. }, // Automatic Anki updates and media generation options.
// ========================================== // ==========================================
+1 -1
View File
@@ -227,7 +227,7 @@ Install ffsubsync or configure the path:
If subtitle sync fails (the error message is prefixed with the engine name): If subtitle sync fails (the error message is prefixed with the engine name):
- Ensure the reference subtitle track exists in the video (alass requires a source track). - Ensure a reference is selected (alass needs either a second subtitle track or the local video file, and it cannot be the same track that is being retimed).
- Check that `ffmpeg` is available (used to extract the internal subtitle track). - Check that `ffmpeg` is available (used to extract the internal subtitle track).
- Try running the sync tool manually to see detailed error output. - Try running the sync tool manually to see detailed error output.
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs). - ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
+1 -1
View File
@@ -149,7 +149,7 @@ Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for sta
- `--log-level` controls logger verbosity. - `--log-level` controls logger verbosity.
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases. - `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set. - `--background` starts at the default quieter logging level (`warn`), then follows `logging.level` after config loads. An explicit `--log-level` remains the override.
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows). - `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`). - Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
- On Hyprland and other Wayland compositors, the tray icon appears only when your panel provides a StatusNotifier/AppIndicator tray host. - On Hyprland and other Wayland compositors, the tray icon appears only when your panel provides a StatusNotifier/AppIndicator tray host.
+3 -1
View File
@@ -64,7 +64,7 @@ Use the basic subtitle websocket when you only need the current subtitle line as
- **Client auth:** none - **Client auth:** none
- **Reconnects:** client-managed - **Reconnects:** client-managed
When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes. When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes. Annotation-only upgrades do not repeat the same line on this basic stream.
#### Message shape #### Message shape
@@ -96,6 +96,8 @@ Use the annotation websocket for custom clients that want the same structured to
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer. In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
On a tokenization cache miss, this stream first sends the cue as plain text with an empty `tokens` array, then sends the annotated replacement when tokenization finishes. Treat each message as the complete current state, replacing the previous payload.
#### Message shape #### Message shape
```json ```json
+1 -1
View File
@@ -11,7 +11,7 @@ SubMiner auto-loads Japanese subtitles when you play a YouTube URL, giving you t
When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback: When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback:
1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata. 1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata. Every yt-dlp call passes `--no-playlist`, so playlist links (for example a Watch Later URL with `list=`/`index=`) resolve to the single video instead of the whole playlist.
2. **Discover** --- Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL. 2. **Discover** --- Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
3. **Select** --- SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto). 3. **Select** --- SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication. 4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
@@ -64,18 +64,23 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
A cue parser extracts both timing and text content from subtitle files for prefetching. A cue parser extracts both timing and text content from subtitle files for prefetching.
**Parsed cue structure:** **Parsed cue structure:**
```typescript ```typescript
interface SubtitleCue { interface SubtitleCue {
startTime: number; // seconds startTime: number; // seconds
endTime: number; // seconds endTime: number; // seconds
text: string; // raw subtitle text text: string; // plain text, decoded from the source format
} }
``` ```
**Supported formats:** **Supported formats:**
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks. - SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, split on the first 9 commas only (ASS v4+ has 10 fields; the last field is Text which can itself contain commas). Strip ASS override tags (`{\...}`) from the text before storing. - ASS: Parse `[Events]` section, extract `Dialogue:` lines, read the field order from the `Format:` row, and take everything after the Text field index as the text (Text can itself contain commas).
ASS text fields contain inline override tags like `{\b1}`, `{\an8}`, `{\fad(200,300)}`. The cue parser strips these during extraction so the tokenizer receives clean text.
**ASS decoding.** The parser is where ASS text is decoded, once, via `assToPlainText()` in `src/core/services/ass-text.ts`. That decoder mirrors mpv's `ass_to_plaintext` so a cue read from a file reads identically to the same line arriving live on `sub-text`: `{...}` override blocks are markup, `\pN … \p0` vector drawing runs are dropped rather than shown as text, `\N`/`\n`/`\h` are the only escapes (`\{`, `\}` and `\\` are not), and an unclosed `{` is rendered verbatim. Every layer downstream — renderer, timing tracker, tokenizer, tokenization cache keys — receives plain text and uses `normalizePlainSubtitleText()` for whitespace only, so nothing decodes the same string twice and one authored line always maps to one cache key.
**Duplicate collapsing.** Typeset scripts emit one `Dialogue:` event per animation frame, plus layered copies of the same line. The parser collapses identical text over an identical span unconditionally, and collapses contiguous same-text runs of at least three events when the run looks like an animation. For ASS that means shared style and actor plus authoring evidence: a temporal tag (`\t`, `\move`, `\k`/`\kf`/`\ko`/`\K`, or anything wrapped in `\t(...)`), an animated `Effect` column (`Karaoke`, `Banner`, `Scroll`), or override values that change across the run. Static tags shared by every event (`\pos`, an identical `\clip`) are not evidence. SRT/VTT carry no such metadata, so there collapsing needs at least five contiguous events all under 0.1s — the frame timing left behind by ASS-to-SRT conversion. The parser keeps this authoring metadata (style, actor, layer, `Effect`, parsed override commands, source order) private; `parseSubtitleCues()` returns only `SubtitleCue`.
#### Prefetch Service Lifecycle #### Prefetch Service Lifecycle
@@ -153,6 +158,7 @@ tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
### Dependency Analysis ### Dependency Analysis
All annotations either depend on MeCab POS data or benefit from running after it: All annotations either depend on MeCab POS data or benefit from running after it:
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately. - **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data. - **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data. - **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
@@ -169,18 +175,14 @@ function annotateTokens(tokens, deps, options): MergedToken[] {
// Single pass: known word + frequency filtering + JLPT computed together // Single pass: known word + frequency filtering + JLPT computed together
const annotated = tokens.map((token) => { const annotated = tokens.map((token) => {
const isKnown = nPlusOneEnabled const isKnown = nPlusOneEnabled ? token.isKnown || computeIsKnown(token, deps) : false;
? token.isKnown || computeIsKnown(token, deps)
: false;
// Filter frequency rank using POS exclusions (rank values already set at parser level) // Filter frequency rank using POS exclusions (rank values already set at parser level)
const frequencyRank = frequencyEnabled const frequencyRank = frequencyEnabled
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions) ? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
: undefined; : undefined;
const jlptLevel = jlptEnabled const jlptLevel = jlptEnabled ? computeJlptLevel(token, deps.getJlptLevel) : undefined;
? computeJlptLevel(token, deps.getJlptLevel)
: undefined;
return { ...token, isKnown, frequencyRank, jlptLevel }; return { ...token, isKnown, frequencyRank, jlptLevel };
}); });
@@ -221,6 +223,7 @@ Replace `document.createElement('span')` calls in the renderer with `templateSpa
### Current Behavior ### Current Behavior
In `renderWithTokens` (`subtitle-render.ts`), each render cycle: In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
1. Clears DOM with `innerHTML = ''` 1. Clears DOM with `innerHTML = ''`
2. Creates a `DocumentFragment` 2. Creates a `DocumentFragment`
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle) 3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
@@ -257,7 +260,7 @@ Full recycling (collecting old nodes, clearing attributes, reusing them) require
## Combined Impact Summary ## Combined Impact Summary
| Scenario | Before | After | Improvement | | Scenario | Before | After | Improvement |
|----------|--------|-------|-------------| | --------------------------------- | ---------- | ---------- | ----------- |
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% | | Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% | | Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% | | Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
@@ -267,16 +270,19 @@ Full recycling (collecting old nodes, clearing attributes, reusing them) require
## Files Summary ## Files Summary
### New Files ### New Files
- `src/core/services/subtitle-prefetch.ts` - `src/core/services/subtitle-prefetch.ts`
- `src/core/services/subtitle-cue-parser.ts` - `src/core/services/subtitle-cue-parser.ts`
### Modified Files ### Modified Files
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`) - `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass) - `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
- `src/renderer/subtitle-render.ts` (template cloneNode) - `src/renderer/subtitle-render.ts` (template cloneNode)
- `src/main.ts` (wire up prefetch service) - `src/main.ts` (wire up prefetch service)
### Test Files ### Test Files
- New tests for subtitle cue parser (SRT, VTT, ASS formats) - New tests for subtitle cue parser (SRT, VTT, ASS formats)
- New tests for subtitle prefetch service (priority window, seek, pause/resume) - New tests for subtitle prefetch service (priority window, seek, pause/resume)
- Updated tests for annotation stage (same behavior, new implementation) - Updated tests for annotation stage (same behavior, new implementation)
+18 -5
View File
@@ -3,7 +3,7 @@
# Subtitle Overlay Priming # Subtitle Overlay Priming
Status: active Status: active
Last verified: 2026-06-14 Last verified: 2026-08-04
Owner: Kyle Yasuda Owner: Kyle Yasuda
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
@@ -47,18 +47,31 @@ subtitles do not draw.
`emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles. `emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles.
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and 6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
`emitSubtitle(cachedPayload)`, then prime secondary subtitles. `emitSubtitle(cachedPayload)`, then prime secondary subtitles.
7. Cache miss: call `refreshCurrentSubtitle(text)` and let normal tokenization emit the final 7. Cache miss: call `refreshCurrentSubtitle(text)`. Normal processing emits a plain payload
payload. synchronously, then replaces it with the tokenized payload when ready.
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching `subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
`subtitleProcessingController` method. This gives the visible overlay priority over background `subtitleProcessingController` method. This gives the visible overlay priority over background
prefetch work and re-centers prefetch around the live playback time. prefetch work and re-centers prefetch around the live playback time.
## Live Cue Delivery
- A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so
live work does not contend for Yomitan state.
- If a newer cue arrives while an older line is still tokenizing, the newer plain cue or empty
clear payload is emitted immediately. The older tokenization result is dropped before it can
replace the current cue.
- The current cue upgrades in place when its tokens and annotations are ready. This can reflow text
or character images, but cue visibility does not wait for that work.
## Emitted State ## Emitted State
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`, which sends the normal - `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation
annotated subtitle payload to overlay windows and subtitle websocket listeners. websocket listeners receive both the immediate plain cue and its later annotation upgrade.
- The basic subtitle websocket receives the immediate plain cue only. Because its serialized
payload discards annotations, the later upgrade would be an identical duplicate and is skipped
when text and cue timing match.
- Secondary priming reads mpv `secondary-sub-text`, stores it in - Secondary priming reads mpv `secondary-sub-text`, stores it in
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows. `mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is - If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
+6 -4
View File
@@ -2,7 +2,7 @@
"name": "subminer", "name": "subminer",
"productName": "SubMiner", "productName": "SubMiner",
"desktopName": "SubMiner.desktop", "desktopName": "SubMiner.desktop",
"version": "0.19.0", "version": "0.19.1",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",
@@ -84,8 +84,9 @@
"overrides": { "overrides": {
"@xmldom/xmldom": "0.8.13", "@xmldom/xmldom": "0.8.13",
"app-builder-lib": "26.15.3", "app-builder-lib": "26.15.3",
"brace-expansion": "5.0.8", "brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.15.3", "electron-builder-squirrel-windows": "26.15.3",
"fast-uri": "3.1.5",
"form-data": "4.0.6", "form-data": "4.0.6",
"ip-address": "10.2.0", "ip-address": "10.2.0",
"js-yaml": "4.3.0", "js-yaml": "4.3.0",
@@ -93,7 +94,8 @@
"minimatch": "10.2.5", "minimatch": "10.2.5",
"picomatch": "4.0.4", "picomatch": "4.0.4",
"tar": "7.5.21", "tar": "7.5.21",
"tmp": "0.2.7" "tmp": "0.2.7",
"undici": "7.29.0"
}, },
"keywords": [ "keywords": [
"anki", "anki",
@@ -125,7 +127,7 @@
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"electron": "42.6.0", "electron": "42.6.0",
"electron-builder": "26.15.3", "electron-builder": "26.15.3",
"undici": "7.28.0", "undici": "7.29.0",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"eslint": "^10.8.0", "eslint": "^10.8.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
-67
View File
@@ -1,67 +0,0 @@
## Highlights
### Added
- **Anki Maturity Known-Word Highlighting**
- Subtitle words you already know can now be color-coded by their Anki card maturity (new, learning, young, mature), like asbplayer's known-word coloring.
- Enable it with `ankiConnect.knownWords.maturityEnabled` (or toggle it mid-session); tier colors and the "mature" day threshold are configurable, and the in-session help legend shows the active colors.
- **Cross-Machine Sync for Stats & Watch History**
- Sync immersion stats and watch history between machines over SSH from a new Sync window (tray menu → Sync Stats & History, or `subminer sync --ui`) or the CLI (`subminer sync <host>`).
- Save multiple devices with per-host sync direction, run one-click syncs with live progress, and take manual database snapshots for backup or transfer.
- Windows remotes are supported over OpenSSH, and hosts can auto-sync in the background on a schedule, even during playback.
- **History Menu After Playback**
- After a watch-history episode ends (or mpv closes), the fzf/rofi launcher now offers to play the previous or next episode, rewatch, pick another episode, or quit, right from where you left off. Previous/Next continue across season folders.
- **Delete Entire Library Titles from Stats**
- The stats Library detail view now has a "Delete Entry" action that removes a whole title in one step, episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts, instead of clearing it episode by episode.
- Delete progress (sessions, episodes, or whole titles) now shows app-wide with a progress bar and status toast visible from any tab or window.
- **TsukiHime English Subtitle Downloads**
- Download subtitles for the currently playing video directly from TsukiHime, with Japanese loaded as the primary track and your configured secondary language alongside it.
### Changed
- **Configurable Clipboard-Video Shortcut**
- The "append clipboard video to queue" shortcut is now configurable via `shortcuts.appendClipboardVideoToQueue` instead of fixed.
### Fixed
- **AniList Season Matching**
- Season 2+ episodes now resolve to the correct AniList entry instead of silently falling back to season 1. SubMiner follows AniList's sequel relations to find the right season, and cover art and watch progress now use the same season-aware match.
- If a season still can't be found, SubMiner no longer force-writes progress or a cover to the season 1 entry, it skips the update and points you to a manual AniList override, which now fixes the character dictionary and watch progress together.
- Manual overrides now stay applied consistently across every episode in a season folder, even when filenames guess differently episode to episode.
- **Subtitle Highlighting Accuracy**
- Fixed several known-word/annotation edge cases: part-of-speech exclusions now apply consistently to merged quote-particle tokens, annotations for rarer kanji are preserved, katakana punctuation is no longer mistaken for plain kana, and a specific noun-tagging case no longer loses its known+1 highlight.
- **AnkiConnect Proxy Port Conflicts**
- Fixed a crash on video startup when another process already held the configured AnkiConnect proxy port; you'll now get a notification explaining how to resolve it instead.
- **AppImage Crash Notification on Quit**
- Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
- **Startup Playback Pause Timing**
- Fixed playback occasionally resuming a couple seconds before subtitle tokenization actually finished warming up, most noticeable when resuming mid-episode or when a subtitle appears in the first two seconds.
- **Stats Library Cover After Relinking**
- Fixed the stats Library grid showing a stale cover image after relinking a title to a different AniList entry.
- **Faster Stats Deletes and Vocabulary Tab**
- Deleting sessions, episodes, and titles from stats is now dramatically faster and no longer stalls playback while it runs; the Vocabulary tab also loads much faster.
- The first launch after updating runs a one-time database migration (a few seconds, database grows about 20%); no action needed.
- **Settings Validation and Stats Server Hardening**
- Invalid AnkiConnect settings now fall back safely with a warning instead of silently breaking, and the stats server is hardened against malformed requests, stalled AniList searches, and other edge cases that could previously crash it.
- **Rofi Prompt Spacing**
- Fixed rofi menu prompts running into the search placeholder text with no space between them.
## What's Changed
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
- refactor(tokenizer): extract subtitle annotation filter into rule table by @ksyasuda in #162
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
- Anki maturity-based known-word highlighting by @ksyasuda in #172
- fix(anilist): resolve later seasons via sequel relations, not title guessing by @ksyasuda in #173
- feat(stats): add library entry deletion and app-wide delete progress by @ksyasuda in #174
## 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`.
+7 -74
View File
@@ -22,10 +22,12 @@ import { MediaGenerator } from './media-generator';
import path from 'path'; import path from 'path';
import { import {
AnkiConnectConfig, AnkiConnectConfig,
type CardKind,
KikuDuplicateCardInfo, KikuDuplicateCardInfo,
KikuFieldGroupingChoice, KikuFieldGroupingChoice,
KikuMergePreviewResponse, KikuMergePreviewResponse,
NotificationOptions, NotificationOptions,
type WordCardKind,
} from './types/anki'; } from './types/anki';
import { AiConfig } from './types/integrations'; import { AiConfig } from './types/integrations';
import type { KnownWordMaturityTier } from './types/subtitle'; import type { KnownWordMaturityTier } from './types/subtitle';
@@ -50,6 +52,7 @@ import {
withUpdateProgress, withUpdateProgress,
UiFeedbackState, UiFeedbackState,
} from './anki-integration/ui-feedback'; } from './anki-integration/ui-feedback';
import { applyCardKindFlagFields, resolveWordCardKindSetting } from './anki-integration/card-kinds';
import { KnownWordCacheManager } from './anki-integration/known-word-cache'; import { KnownWordCacheManager } from './anki-integration/known-word-cache';
import { PollingRunner } from './anki-integration/polling'; import { PollingRunner } from './anki-integration/polling';
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy'; import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
@@ -83,8 +86,6 @@ interface NoteInfo {
fields: Record<string, { value: string }>; fields: Record<string, { value: string }>;
} }
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
function trimToNonEmptyString(value: unknown): string | null { function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') return null; if (typeof value !== 'string') return null;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -840,6 +841,7 @@ export class AnkiIntegration {
kikuEnabled: boolean; kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
kikuDeleteDuplicateInAuto: boolean; kikuDeleteDuplicateInAuto: boolean;
wordCardKind: WordCardKind;
} { } {
const lapis = this.getLapisConfig(); const lapis = this.getLapisConfig();
const kiku = this.getKikuConfig(); const kiku = this.getKikuConfig();
@@ -852,6 +854,7 @@ export class AnkiIntegration {
kikuEnabled: kiku.enabled, kikuEnabled: kiku.enabled,
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled', kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false, kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind),
}; };
} }
@@ -1315,79 +1318,9 @@ export class AnkiIntegration {
availableFieldNames: string[], availableFieldNames: string[],
cardKind: CardKind, cardKind: CardKind,
): void { ): void {
const audioFlagNames = ['IsAudioCard']; applyCardKindFlagFields(updatedFields, cardKind, (preferredName) =>
this.resolveFieldName(availableFieldNames, preferredName),
if (cardKind === 'word-and-sentence') {
const wordAndSentenceFlag = this.resolveFieldName(
availableFieldNames,
'IsWordAndSentenceCard',
); );
if (!wordAndSentenceFlag) {
return;
}
updatedFields[wordAndSentenceFlag] = 'x';
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
if (sentenceFlag && sentenceFlag !== wordAndSentenceFlag) {
updatedFields[sentenceFlag] = '';
}
for (const audioFlagName of audioFlagNames) {
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
if (resolved && resolved !== wordAndSentenceFlag) {
updatedFields[resolved] = '';
}
}
return;
}
if (cardKind === 'sentence') {
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
if (sentenceFlag) {
updatedFields[sentenceFlag] = 'x';
}
for (const audioFlagName of audioFlagNames) {
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
if (resolved && resolved !== sentenceFlag) {
updatedFields[resolved] = '';
}
}
const wordAndSentenceFlag = this.resolveFieldName(
availableFieldNames,
'IsWordAndSentenceCard',
);
if (wordAndSentenceFlag && wordAndSentenceFlag !== sentenceFlag) {
updatedFields[wordAndSentenceFlag] = '';
}
return;
}
const resolvedAudioFlags = Array.from(
new Set(
audioFlagNames
.map((name) => this.resolveFieldName(availableFieldNames, name))
.filter((name): name is string => Boolean(name)),
),
);
const audioFlagName = resolvedAudioFlags[0] || null;
if (audioFlagName) {
updatedFields[audioFlagName] = 'x';
}
for (const extraAudioFlag of resolvedAudioFlags.slice(1)) {
updatedFields[extraAudioFlag] = '';
}
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
if (sentenceFlag && sentenceFlag !== audioFlagName) {
updatedFields[sentenceFlag] = '';
}
const wordAndSentenceFlag = this.resolveFieldName(availableFieldNames, 'IsWordAndSentenceCard');
if (wordAndSentenceFlag && wordAndSentenceFlag !== audioFlagName) {
updatedFields[wordAndSentenceFlag] = '';
}
} }
private async showNotification( private async showNotification(
@@ -4,29 +4,23 @@ import test from 'node:test';
import { CardCreationService } from './card-creation'; import { CardCreationService } from './card-creation';
import { toMpvEdlValue } from './mpv-edl-test-utils'; import { toMpvEdlValue } from './mpv-edl-test-utils';
import type { MediaInput } from '../media-generator'; import type { MediaInput } from '../media-generator';
import type { AnkiConnectConfig } from '../types/anki'; import type { AnkiConnectConfig, CardKind } from '../types/anki';
import { applyCardKindFlagFields } from './card-kinds';
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0]; type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
function setWordAndSentenceCardTypeFields( function setCardTypeFields(
updatedFields: Record<string, string>, updatedFields: Record<string, string>,
availableFieldNames: string[], availableFieldNames: string[],
cardKind: 'sentence' | 'audio' | 'word-and-sentence', cardKind: CardKind,
): void { ): void {
if (cardKind !== 'word-and-sentence') return; applyCardKindFlagFields(
updatedFields,
const resolveFieldName = (preferredName: string): string | null => cardKind,
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null; (preferredName) =>
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard'); availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
if (!wordAndSentenceFlag) return; null,
);
updatedFields[wordAndSentenceFlag] = 'x';
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
const resolved = resolveFieldName(flagName);
if (resolved && resolved !== wordAndSentenceFlag) {
updatedFields[resolved] = '';
}
}
} }
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): { function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
@@ -217,7 +211,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
kikuFieldGrouping: 'disabled', kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false, kikuDeleteDuplicateInAuto: false,
}), }),
setCardTypeFields: setWordAndSentenceCardTypeFields, setCardTypeFields,
}); });
await service.updateLastAddedFromClipboard('字幕'); await service.updateLastAddedFromClipboard('字幕');
+6 -10
View File
@@ -3,7 +3,7 @@ import {
getConfiguredWordFieldName, getConfiguredWordFieldName,
getPreferredWordValueFromExtractedFields, getPreferredWordValueFromExtractedFields,
} from '../anki-field-config'; } from '../anki-field-config';
import { AnkiConnectConfig } from '../types/anki'; import { AnkiConnectConfig, type CardKind, type WordCardKind } from '../types/anki';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
import type { MediaInput } from '../media-input'; import type { MediaInput } from '../media-input';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
@@ -15,7 +15,7 @@ import {
resolveAudioStreamIndexForMediaGeneration, resolveAudioStreamIndexForMediaGeneration,
type MediaGenerationInputResolverOptions, type MediaGenerationInputResolverOptions,
} from './media-source'; } from './media-source';
import { shouldMarkWordAndSentenceCard } from './note-field-utils'; import { resolveWordCardKind } from './note-field-utils';
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media'; import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
import { resolveMpvVolumeScale } from './mpv-volume'; import { resolveMpvVolumeScale } from './mpv-volume';
@@ -42,8 +42,6 @@ export interface CardCreationNoteInfo {
fields: Record<string, { value: string }>; fields: Record<string, { value: string }>;
} }
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
interface CardCreationClient { interface CardCreationClient {
addNote( addNote(
deck: string, deck: string,
@@ -136,6 +134,7 @@ interface CardCreationDeps {
kikuEnabled: boolean; kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
kikuDeleteDuplicateInAuto: boolean; kikuDeleteDuplicateInAuto: boolean;
wordCardKind?: WordCardKind;
}; };
getFallbackDurationSeconds: () => number; getFallbackDurationSeconds: () => number;
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void; appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
@@ -274,12 +273,9 @@ export class CardCreationService {
if (sentenceField) { if (sentenceField) {
const processedSentence = this.deps.processSentence(sentence, fields); const processedSentence = this.deps.processSentence(sentence, fields);
updatedFields[sentenceField] = processedSentence; updatedFields[sentenceField] = processedSentence;
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) { const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
this.deps.setCardTypeFields( if (wordCardKind) {
updatedFields, this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
Object.keys(noteInfo.fields),
'word-and-sentence',
);
} }
updatePerformed = true; updatePerformed = true;
} }
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyCardKindFlagFields } from './card-kinds';
function resolverFor(availableFieldNames: string[]) {
return (preferredName: string): string | null =>
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
}
const KIKU_FLAG_FIELDS = ['IsWordAndSentenceCard', 'IsClickCard', 'IsSentenceCard', 'IsAudioCard'];
test('flags the requested card kind and clears the others', () => {
const fields: Record<string, string> = {};
applyCardKindFlagFields(fields, 'click', resolverFor(KIKU_FLAG_FIELDS));
assert.deepEqual(fields, {
IsClickCard: 'x',
IsWordAndSentenceCard: '',
IsSentenceCard: '',
IsAudioCard: '',
});
});
test('matches flag fields case-insensitively', () => {
const fields: Record<string, string> = {};
applyCardKindFlagFields(fields, 'word-and-sentence', resolverFor(['iswordandsentencecard']));
assert.deepEqual(fields, { iswordandsentencecard: 'x' });
});
test('leaves flags untouched when the note type has no flag for a word card kind', () => {
const fields: Record<string, string> = {};
applyCardKindFlagFields(
fields,
'click',
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
);
assert.deepEqual(fields, {});
});
test('clears stale flags for explicit mine actions even without the target flag', () => {
const fields: Record<string, string> = {};
applyCardKindFlagFields(
fields,
'audio',
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
);
assert.deepEqual(fields, { IsWordAndSentenceCard: '', IsSentenceCard: '' });
});
test('does not blank the target flag it just set', () => {
const fields: Record<string, string> = {};
applyCardKindFlagFields(fields, 'sentence', resolverFor(['IsSentenceCard']));
assert.deepEqual(fields, { IsSentenceCard: 'x' });
});
+63
View File
@@ -0,0 +1,63 @@
import type { CardKind, WordCardKind } from '../types/anki';
/**
* Kiku/Lapis note types decide which card a note generates from mutually exclusive
* `Is...Card` flag fields. Setting one always means clearing the others.
*/
export const CARD_KIND_FLAG_FIELDS: Record<CardKind, string> = {
'word-and-sentence': 'IsWordAndSentenceCard',
click: 'IsClickCard',
sentence: 'IsSentenceCard',
audio: 'IsAudioCard',
};
export const WORD_CARD_KINDS: readonly WordCardKind[] = [
'word-and-sentence',
'click',
'sentence',
'audio',
'none',
];
export const DEFAULT_WORD_CARD_KIND: WordCardKind = 'word-and-sentence';
/**
* Card kinds SubMiner marks on its own initiative (word cards). They are only applied
* when the note type actually carries the matching flag field, so plain note types keep
* their fields untouched.
*/
const IMPLICIT_CARD_KINDS = new Set<CardKind>(['word-and-sentence', 'click']);
export function isWordCardKind(value: unknown): value is WordCardKind {
return typeof value === 'string' && WORD_CARD_KINDS.includes(value as WordCardKind);
}
export function resolveWordCardKindSetting(value: unknown): WordCardKind {
return isWordCardKind(value) ? value : DEFAULT_WORD_CARD_KIND;
}
/**
* Flags `cardKind` on the note and clears every other card-kind flag it has, so the note
* never ends up claiming to be two kinds of card at once.
*/
export function applyCardKindFlagFields(
updatedFields: Record<string, string>,
cardKind: CardKind,
resolveFieldName: (preferredName: string) => string | null,
): void {
const targetFlag = resolveFieldName(CARD_KIND_FLAG_FIELDS[cardKind]);
if (!targetFlag && IMPLICIT_CARD_KINDS.has(cardKind)) {
return;
}
if (targetFlag) {
updatedFields[targetFlag] = 'x';
}
for (const [kind, flagName] of Object.entries(CARD_KIND_FLAG_FIELDS)) {
if (kind === cardKind) continue;
const resolved = resolveFieldName(flagName);
if (resolved && resolved !== targetFlag) {
updatedFields[resolved] = '';
}
}
}
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveWordCardKind, type NoteFieldValueInfo } from './note-field-utils';
function kikuNote(values: Record<string, string> = {}): NoteFieldValueInfo {
const defaults: Record<string, string> = {
Expression: '単語',
Sentence: '',
IsWordAndSentenceCard: '',
IsClickCard: '',
IsSentenceCard: '',
IsAudioCard: '',
};
return {
fields: Object.fromEntries(
Object.entries({ ...defaults, ...values }).map(([name, value]) => [name, { value }]),
),
};
}
test('marks word-and-sentence cards by default when Kiku is enabled', () => {
assert.equal(
resolveWordCardKind(kikuNote(), { lapisEnabled: false, kikuEnabled: true }),
'word-and-sentence',
);
});
test('honors the configured word card kind', () => {
assert.equal(
resolveWordCardKind(kikuNote(), {
lapisEnabled: false,
kikuEnabled: true,
wordCardKind: 'click',
}),
'click',
);
});
test('marks nothing when neither Kiku nor Lapis is enabled', () => {
assert.equal(
resolveWordCardKind(kikuNote(), {
lapisEnabled: false,
kikuEnabled: false,
wordCardKind: 'click',
}),
null,
);
});
test('marks nothing when the word card kind is "none"', () => {
assert.equal(
resolveWordCardKind(kikuNote(), {
lapisEnabled: true,
kikuEnabled: false,
wordCardKind: 'none',
}),
null,
);
});
test('falls back to the default kind for an unrecognized setting', () => {
assert.equal(
resolveWordCardKind(kikuNote(), {
lapisEnabled: false,
kikuEnabled: true,
wordCardKind: 'bogus' as never,
}),
'word-and-sentence',
);
});
test('marks nothing when the note type lacks the configured flag field', () => {
const note: NoteFieldValueInfo = {
fields: { Expression: { value: '単語' }, Sentence: { value: '' } },
};
assert.equal(
resolveWordCardKind(note, { lapisEnabled: false, kikuEnabled: true, wordCardKind: 'click' }),
null,
);
});
test('leaves cards already mined as sentence or audio cards alone', () => {
for (const flagField of ['IsSentenceCard', 'IsAudioCard']) {
assert.equal(
resolveWordCardKind(kikuNote({ [flagField]: 'x' }), {
lapisEnabled: false,
kikuEnabled: true,
wordCardKind: 'click',
}),
null,
flagField,
);
}
});
test('re-affirms the configured kind when the note already carries its flag', () => {
assert.equal(
resolveWordCardKind(kikuNote({ IsSentenceCard: 'x' }), {
lapisEnabled: false,
kikuEnabled: true,
wordCardKind: 'sentence',
}),
'sentence',
);
});
test('overrides a differently flagged word card', () => {
assert.equal(
resolveWordCardKind(kikuNote({ IsWordAndSentenceCard: 'x' }), {
lapisEnabled: false,
kikuEnabled: true,
wordCardKind: 'click',
}),
'click',
);
});
+60 -15
View File
@@ -1,3 +1,13 @@
import type { CardKind, WordCardKind } from '../types/anki';
import { createLogger } from '../logger';
import {
CARD_KIND_FLAG_FIELDS,
DEFAULT_WORD_CARD_KIND,
resolveWordCardKindSetting,
} from './card-kinds';
const log = createLogger('anki').child('integration.note-fields');
export interface NoteFieldValueInfo { export interface NoteFieldValueInfo {
fields: Record<string, { value: string }>; fields: Record<string, { value: string }>;
} }
@@ -16,22 +26,57 @@ export function hasNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: s
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0; return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
} }
export function shouldMarkWordAndSentenceCard( /** Flags set only by an explicit mine action; a note carrying one is not a word card. */
noteInfo: NoteFieldValueInfo, const EXPLICIT_CARD_FLAG_FIELDS = [CARD_KIND_FLAG_FIELDS.sentence, CARD_KIND_FLAG_FIELDS.audio];
sentenceCardConfig: { lapisEnabled: boolean; kikuEnabled: boolean },
): boolean {
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
return false;
}
const wordAndSentenceValue = getNoteFieldValue(noteInfo, 'IsWordAndSentenceCard'); const warnedMissingFlagFields = new Set<CardKind>();
if (wordAndSentenceValue === null) {
return false; function warnMissingFlagFieldOnce(wordCardKind: CardKind, flagField: string): void {
if (wordCardKind === DEFAULT_WORD_CARD_KIND || warnedMissingFlagFields.has(wordCardKind)) {
// The default kind is also the fallback for plain note types, so its absence is expected.
return;
} }
if (wordAndSentenceValue.trim().length > 0) { warnedMissingFlagFields.add(wordCardKind);
return true; log.warn(
} `Word card type "${wordCardKind}" is configured but the note has no ${flagField} field; leaving card type flags unchanged.`,
return (
!hasNoteFieldValue(noteInfo, 'IsSentenceCard') && !hasNoteFieldValue(noteInfo, 'IsAudioCard')
); );
} }
/**
* Card kind to flag when SubMiner fills a word card's sentence, or null to leave the
* card-kind flags alone. Kiku/Lapis only: other note types have no such fields.
*/
export function resolveWordCardKind(
noteInfo: NoteFieldValueInfo,
sentenceCardConfig: {
lapisEnabled: boolean;
kikuEnabled: boolean;
wordCardKind?: WordCardKind;
},
): CardKind | null {
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
return null;
}
const wordCardKind = resolveWordCardKindSetting(sentenceCardConfig.wordCardKind);
if (wordCardKind === 'none') {
return null;
}
const flagField = CARD_KIND_FLAG_FIELDS[wordCardKind];
const flagValue = getNoteFieldValue(noteInfo, flagField);
if (flagValue === null) {
// Note type has no flag field for the configured kind.
warnMissingFlagFieldOnce(wordCardKind, flagField);
return null;
}
if (flagValue.trim().length > 0) {
return wordCardKind;
}
const alreadyExplicitCard = EXPLICIT_CARD_FLAG_FIELDS.some(
(fieldName) =>
fieldName.toLowerCase() !== flagField.toLowerCase() && hasNoteFieldValue(noteInfo, fieldName),
);
return alreadyExplicitCard ? null : wordCardKind;
}
@@ -6,26 +6,21 @@ import {
type NoteUpdateWorkflowNoteInfo, type NoteUpdateWorkflowNoteInfo,
} from './note-update-workflow'; } from './note-update-workflow';
import type { SubtitleMiningContext } from '../types/subtitle'; import type { SubtitleMiningContext } from '../types/subtitle';
import type { CardKind } from '../types/anki';
import { applyCardKindFlagFields } from './card-kinds';
function setWordAndSentenceCardTypeFields( function setCardTypeFields(
updatedFields: Record<string, string>, updatedFields: Record<string, string>,
availableFieldNames: string[], availableFieldNames: string[],
cardKind: 'word-and-sentence', cardKind: CardKind,
): void { ): void {
assert.equal(cardKind, 'word-and-sentence'); applyCardKindFlagFields(
const resolveFieldName = (preferredName: string): string | null => updatedFields,
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null; cardKind,
(preferredName) =>
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard'); availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
if (!wordAndSentenceFlag) return; null,
);
updatedFields[wordAndSentenceFlag] = 'x';
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
const resolved = resolveFieldName(flagName);
if (resolved && resolved !== wordAndSentenceFlag) {
updatedFields[resolved] = '';
}
}
} }
function createWorkflowHarness() { function createWorkflowHarness() {
@@ -79,7 +74,7 @@ function createWorkflowHarness() {
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) => handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
false, false,
processSentence: (text: string, _noteFields: Record<string, string>) => text, processSentence: (text: string, _noteFields: Record<string, string>) => text,
setCardTypeFields: setWordAndSentenceCardTypeFields, setCardTypeFields,
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => { resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
if (!preferred) return null; if (!preferred) return null;
const names = Object.keys(noteInfo.fields); const names = Object.keys(noteInfo.fields);
@@ -183,6 +178,73 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car
}); });
}); });
test('NoteUpdateWorkflow marks the configured word card kind instead of word-and-sentence', async () => {
const harness = createWorkflowHarness();
harness.deps.getEffectiveSentenceCardConfig = () => ({
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'manual',
wordCardKind: 'click',
});
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
IsWordAndSentenceCard: { value: 'x' },
IsClickCard: { value: '' },
IsSentenceCard: { value: '' },
IsAudioCard: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Sentence: 'subtitle-text',
IsClickCard: 'x',
IsWordAndSentenceCard: '',
IsSentenceCard: '',
IsAudioCard: '',
});
});
test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is none', async () => {
const harness = createWorkflowHarness();
harness.deps.getEffectiveSentenceCardConfig = () => ({
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'manual',
wordCardKind: 'none',
});
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
IsWordAndSentenceCard: { value: '' },
IsSentenceCard: { value: '' },
IsAudioCard: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Sentence: 'subtitle-text',
});
});
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => { test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
const harness = createWorkflowHarness(); const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () => harness.deps.client.notesInfo = async () =>
+7 -8
View File
@@ -1,7 +1,8 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config'; import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
import type { SubtitleMiningContext } from '../types/subtitle'; import type { SubtitleMiningContext } from '../types/subtitle';
import { shouldMarkWordAndSentenceCard } from './note-field-utils'; import type { CardKind, WordCardKind } from '../types/anki';
import { resolveWordCardKind } from './note-field-utils';
export interface NoteUpdateWorkflowNoteInfo { export interface NoteUpdateWorkflowNoteInfo {
noteId: number; noteId: number;
@@ -39,6 +40,7 @@ export interface NoteUpdateWorkflowDeps {
lapisEnabled: boolean; lapisEnabled: boolean;
kikuEnabled: boolean; kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled'; kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
wordCardKind?: WordCardKind;
}; };
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void; appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>; extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
@@ -67,7 +69,7 @@ export interface NoteUpdateWorkflowDeps {
setCardTypeFields: ( setCardTypeFields: (
updatedFields: Record<string, string>, updatedFields: Record<string, string>,
availableFieldNames: string[], availableFieldNames: string[],
cardKind: 'word-and-sentence', cardKind: CardKind,
) => void; ) => void;
resolveConfiguredFieldName: ( resolveConfiguredFieldName: (
noteInfo: NoteUpdateWorkflowNoteInfo, noteInfo: NoteUpdateWorkflowNoteInfo,
@@ -207,12 +209,9 @@ export class NoteUpdateWorkflow {
if (sentenceField && currentSubtitleText) { if (sentenceField && currentSubtitleText) {
const processedSentence = this.deps.processSentence(currentSubtitleText, fields); const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
updatedFields[sentenceField] = processedSentence; updatedFields[sentenceField] = processedSentence;
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) { const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
this.deps.setCardTypeFields( if (wordCardKind) {
updatedFields, this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
Object.keys(noteInfo.fields),
'word-and-sentence',
);
} }
updatePerformed = true; updatePerformed = true;
} }
+8
View File
@@ -116,6 +116,10 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku, ...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
...(config.isKiku ?? {}), ...(config.isKiku ?? {}),
}, },
lapisKiku: {
...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku,
...(config.lapisKiku ?? {}),
},
} as AnkiConnectConfig; } as AnkiConnectConfig;
} }
@@ -205,6 +209,10 @@ export class AnkiIntegrationRuntime {
patch.isKiku !== undefined patch.isKiku !== undefined
? { ...this.config.isKiku, ...patch.isKiku } ? { ...this.config.isKiku, ...patch.isKiku }
: this.config.isKiku, : this.config.isKiku,
lapisKiku:
patch.lapisKiku !== undefined
? { ...this.config.lapisKiku, ...patch.lapisKiku }
: this.config.lapisKiku,
}; };
this.config = normalizeAnkiIntegrationConfig(mergedConfig); this.config = normalizeAnkiIntegrationConfig(mergedConfig);
this.deps.onConfigChanged?.(this.config); this.deps.onConfigChanged?.(this.config);
+37
View File
@@ -2738,6 +2738,43 @@ test('ignores deprecated isLapis sentence-card field overrides', () => {
); );
}); });
test('accepts a Kiku/Lapis word card kind and warns on an unknown one', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"isKiku": { "enabled": true },
"lapisKiku": { "wordCardKind": "click" }
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
assert.equal(service.getConfig().ankiConnect.lapisKiku.wordCardKind, 'click');
assert.equal(service.getWarnings().length, 0);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"ankiConnect": {
"lapisKiku": { "wordCardKind": "isClickCard" }
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(invalidService.getConfig().ankiConnect.lapisKiku.wordCardKind, 'word-and-sentence');
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'ankiConnect.lapisKiku.wordCardKind'),
);
});
test('accepts valid ankiConnect knownWords deck object', () => { test('accepts valid ankiConnect knownWords deck object', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
@@ -91,6 +91,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
fieldGrouping: 'disabled', fieldGrouping: 'disabled',
deleteDuplicateInAuto: true, deleteDuplicateInAuto: true,
}, },
lapisKiku: {
wordCardKind: 'word-and-sentence',
},
}, },
jimaku: { jimaku: {
apiBaseUrl: 'https://jimaku.cc', apiBaseUrl: 'https://jimaku.cc',
@@ -1,4 +1,5 @@
import { ResolvedConfig } from '../../types/config'; import { ResolvedConfig } from '../../types/config';
import { WORD_CARD_KINDS } from '../../anki-integration/card-kinds';
import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode'; import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode';
import { import {
NOTIFICATION_TYPE_VALUES, NOTIFICATION_TYPE_VALUES,
@@ -374,6 +375,21 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel, defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
description: 'Note type name used by Lapis sentence cards.', description: 'Note type name used by Lapis sentence cards.',
}, },
{
path: 'ankiConnect.lapisKiku.wordCardKind',
kind: 'enum',
enumValues: WORD_CARD_KINDS,
enumLabels: {
'word-and-sentence': 'Word and sentence card (IsWordAndSentenceCard)',
click: 'Click card (IsClickCard)',
sentence: 'Sentence card (IsSentenceCard)',
audio: 'Audio card (IsAudioCard)',
none: 'Leave card type flags untouched',
},
defaultValue: defaultConfig.ankiConnect.lapisKiku.wordCardKind,
description:
'Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled.',
},
{ {
path: 'ankiConnect.metadata.pattern', path: 'ankiConnect.metadata.pattern',
kind: 'string', kind: 'string',
+1 -1
View File
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'AnkiConnect Integration', title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'], description: ['Automatic Anki updates and media generation options.'],
notes: [ notes: [
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.', 'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
'Shared AI provider transport settings are read from top-level ai and typically require restart.', 'Shared AI provider transport settings are read from top-level ai and typically require restart.',
'Most other AnkiConnect settings still require restart.', 'Most other AnkiConnect settings still require restart.',
], ],
+2
View File
@@ -1,6 +1,7 @@
import type { ResolveContext } from './context'; import type { ResolveContext } from './context';
import { initializeAnkiConnectResolution } from './anki-connect/initialize'; import { initializeAnkiConnectResolution } from './anki-connect/initialize';
import { applyAnkiKikuResolution } from './anki-connect/kiku'; import { applyAnkiKikuResolution } from './anki-connect/kiku';
import { applyAnkiLapisKikuResolution } from './anki-connect/lapis-kiku';
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words'; import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
import { applyAnkiLegacyResolution } from './anki-connect/legacy'; import { applyAnkiLegacyResolution } from './anki-connect/legacy';
import { applyAnkiModernResolution } from './anki-connect/modern'; import { applyAnkiModernResolution } from './anki-connect/modern';
@@ -22,4 +23,5 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata); applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
applyAnkiKnownWordsResolution(context, ankiConnect, behavior); applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
applyAnkiKikuResolution(context); applyAnkiKikuResolution(context);
applyAnkiLapisKikuResolution(context, ankiConnect);
} }
@@ -77,5 +77,8 @@ export function initializeAnkiConnectResolution(
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku']) ? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
: {}), : {}),
}, },
lapisKiku: {
...context.resolved.ankiConnect.lapisKiku,
},
}; };
} }
@@ -0,0 +1,39 @@
import { isWordCardKind, WORD_CARD_KINDS } from '../../../anki-integration/card-kinds';
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { isObject } from '../shared';
export function applyAnkiLapisKikuResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
if (!isObject(ankiConnect.lapisKiku)) {
if (ankiConnect.lapisKiku !== undefined) {
context.warn(
'ankiConnect.lapisKiku',
ankiConnect.lapisKiku,
context.resolved.ankiConnect.lapisKiku,
'Expected object.',
);
}
return;
}
const wordCardKind = ankiConnect.lapisKiku.wordCardKind;
if (wordCardKind === undefined) {
return;
}
if (isWordCardKind(wordCardKind)) {
context.resolved.ankiConnect.lapisKiku.wordCardKind = wordCardKind;
return;
}
context.warn(
'ankiConnect.lapisKiku.wordCardKind',
wordCardKind,
DEFAULT_CONFIG.ankiConnect.lapisKiku.wordCardKind,
`Expected one of ${WORD_CARD_KINDS.join(', ')}.`,
);
context.resolved.ankiConnect.lapisKiku.wordCardKind =
DEFAULT_CONFIG.ankiConnect.lapisKiku.wordCardKind;
}
+9 -1
View File
@@ -221,6 +221,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'ankiConnect.nPlusOne.enabled': 'Enabled', 'ankiConnect.nPlusOne.enabled': 'Enabled',
'ankiConnect.isLapis.enabled': 'Enable Lapis Features', 'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
'ankiConnect.isKiku.enabled': 'Enable Kiku Features', 'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
'ankiConnect.lapisKiku.wordCardKind': 'Word Card Type',
'stats.toggleKey': 'Toggle Stats Overlay', 'stats.toggleKey': 'Toggle Stats Overlay',
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager', 'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar', 'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
@@ -255,6 +256,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.', 'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
'ankiConnect.isLapis.sentenceCardModel': 'ankiConnect.isLapis.sentenceCardModel':
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.', 'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
'ankiConnect.lapisKiku.wordCardKind':
'Card-type flag marked on mined word cards. Only one flag is set at a time; the others are cleared. Requires Kiku or Lapis to be enabled.',
'subtitleStyle.css': 'subtitleStyle.css':
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.', 'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
'subtitleStyle.secondary.css': 'subtitleStyle.secondary.css':
@@ -401,7 +404,11 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('ankiConnect.media.')) { if (path.startsWith('ankiConnect.media.')) {
return { category: 'mining-anki', section: 'Media Capture' }; return { category: 'mining-anki', section: 'Media Capture' };
} }
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) { if (
path.startsWith('ankiConnect.isKiku.') ||
path.startsWith('ankiConnect.isLapis.') ||
path.startsWith('ankiConnect.lapisKiku.')
) {
return { category: 'mining-anki', section: 'Kiku/Lapis Features' }; return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
} }
if (path.startsWith('ankiConnect.ai.')) { if (path.startsWith('ankiConnect.ai.')) {
@@ -702,6 +709,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
path === 'ankiConnect.fields.miscInfo' || path === 'ankiConnect.fields.miscInfo' ||
path === 'ankiConnect.isLapis.sentenceCardModel' || path === 'ankiConnect.isLapis.sentenceCardModel' ||
path === 'ankiConnect.isKiku.fieldGrouping' || path === 'ankiConnect.isKiku.fieldGrouping' ||
path === 'ankiConnect.lapisKiku.wordCardKind' ||
path === 'mpv.aniskipEnabled' || path === 'mpv.aniskipEnabled' ||
path === 'mpv.aniskipButtonKey' || path === 'mpv.aniskipButtonKey' ||
path === 'stats.toggleKey' || path === 'stats.toggleKey' ||
@@ -2454,6 +2454,80 @@ Aligned English subtitle
}); });
}); });
it('POST /api/stats/mine-card marks the configured Kiku word card kind', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
await withFakeAnkiConnect(
async (requests, url) => {
const app = createStatsApp(createMockTracker(), {
addYomitanNote: async () => 777,
createMediaGenerator: () => ({
generateAudio: async () => null,
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
}),
ankiConnectConfig: {
url,
deck: 'Mining',
fields: {
image: 'Picture',
sentence: 'Sentence',
},
media: {
generateAudio: false,
generateImage: false,
},
isKiku: {
enabled: true,
fieldGrouping: 'disabled',
deleteDuplicateInAuto: true,
},
lapisKiku: {
wordCardKind: 'click',
},
},
});
const res = await app.request('/api/stats/mine-card?mode=word', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 1_000,
endMs: 2_000,
sentence: '猫を見た',
word: '猫',
videoTitle: 'Episode 1',
}),
});
const body = await res.json();
assert.equal(res.status, 200, JSON.stringify(body));
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
const fields = updateRequest?.params?.note?.fields ?? {};
assert.equal(fields.IsClickCard, 'x');
assert.equal(fields.IsWordAndSentenceCard, '');
assert.equal(fields.IsSentenceCard, '');
assert.equal(fields.IsAudioCard, '');
},
{
notesInfoFields: {
Expression: { value: '猫' },
Sentence: { value: '' },
Picture: { value: '' },
IsWordAndSentenceCard: { value: '' },
IsClickCard: { value: '' },
IsSentenceCard: { value: '' },
IsAudioCard: { value: '' },
},
},
);
});
});
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => { it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
await withTempDir(async (dir) => { await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv'); const sourcePath = path.join(dir, 'episode.mkv');
+195
View File
@@ -0,0 +1,195 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
assOverrideSignature,
assToPlainText,
collectAssOverrideCommands,
extractAssOverrideBlocks,
hasAssTemporalOverride,
isAnimatedAssEffectKind,
isAssTemporalCommand,
normalizePlainSubtitleText,
parseAssEffectField,
} from './ass-text';
test('assToPlainText drops vector drawing runs', () => {
assert.equal(
assToPlainText(
'{\\an5\\pos(730,1042)\\p1\\blur1}m 20 0 b 10 0 0 10 0 20 b 0 31 10 40 20 40 {\\p0}',
),
'',
);
});
test('assToPlainText keeps text around drawing runs on the same event', () => {
assert.equal(
assToPlainText('{\\p1}m 0 0 l 10 10{\\p0}本文{\\p1}m 5 5 l 6 6{\\p0}続き'),
'本文続き',
);
});
test('assToPlainText leaves \\pos alone when no drawing mode is active', () => {
assert.equal(assToPlainText('{\\pos(960,1068)\\bord3}位置指定'), '位置指定');
});
test('assToPlainText does not read \\pos as a drawing tag', () => {
assert.equal(assToPlainText('{\\p1\\pos(1,2)}m 0 0 l 5 5'), '');
});
test('assToPlainText resolves line-break and space escapes', () => {
assert.equal(assToPlainText('一行目\\N二行目'), '一行目\n二行目');
assert.equal(assToPlainText('一行目\\n二行目'), '一行目\n二行目');
assert.equal(assToPlainText('一行目\\N二行目', ' '), '一行目 二行目');
assert.equal(assToPlainText('間\\h隔'), '間 隔');
});
test('assToPlainText matches mpv on brace and backslash sequences', () => {
// mpv has no `\{` / `\}` / `\\` escapes: the backslashes are literal text and the
// braces still open and close an override block.
assert.equal(assToPlainText('\\{注\\}'), '\\');
assert.equal(assToPlainText('\\\\N'), '\\\n');
});
test('assToPlainText renders an unclosed override block verbatim', () => {
// mpv shows the stray brace; guessing where the block ended can eat a whole line.
assert.equal(assToPlainText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
});
test('assToPlainText is idempotent', () => {
const samples = [
'{\\an5\\p1}m 0 0 l 5 5{\\p0}本文',
'\\{注\\}',
'\\\\N',
'本文{\\pos(1,2)',
'一行目\\N二行目\\h終わり',
];
for (const sample of samples) {
const once = assToPlainText(sample);
assert.equal(assToPlainText(once), once, sample);
}
});
test('assToPlainText normalizes CRLF before converting', () => {
assert.equal(assToPlainText('一行目\r\n二行目'), '一行目\n二行目');
});
test('normalizePlainSubtitleText settles whitespace without decoding ASS', () => {
// A brace reaching this layer is literal text mpv chose to show, not markup.
assert.equal(normalizePlainSubtitleText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
assert.equal(normalizePlainSubtitleText('一行目\\N二行目'), '一行目\n二行目');
assert.equal(
normalizePlainSubtitleText('一行目\\N二行目', { collapseLineBreaks: true }),
'一行目 二行目',
);
assert.equal(normalizePlainSubtitleText(' 余白 ', { trim: false }), ' 余白 ');
});
test('normalizePlainSubtitleText is idempotent', () => {
for (const sample of ['一行目\\N二行目', '間\\h隔', '本文{\\pos(1,2)', ' 余白 ']) {
const once = normalizePlainSubtitleText(sample);
assert.equal(normalizePlainSubtitleText(once), once, sample);
}
});
test('extractAssOverrideBlocks returns block contents', () => {
assert.deepEqual(extractAssOverrideBlocks('{\\an8}上{\\fad(200,200)}下'), [
'\\an8',
'\\fad(200,200)',
]);
assert.deepEqual(extractAssOverrideBlocks('括弧なし'), []);
});
test('collectAssOverrideCommands captures names and arguments from blocks only', () => {
const commands = collectAssOverrideCommands('{\\pos(1,2)\\1c&HFFFFFF&\\kf30}歌詞');
assert.deepEqual(commands, [
{ name: 'pos', args: '1,2', animated: false },
{ name: '1c', args: '&HFFFFFF&', animated: false },
{ name: 'kf', args: '30', animated: false },
]);
// A `\pos(...)` sitting in visible text is not typesetting markup.
assert.deepEqual(collectAssOverrideCommands('\\pos(730,1042) と書いてある'), []);
});
test('collectAssOverrideCommands marks tags animated by a wrapping \\t', () => {
const commands = collectAssOverrideCommands('{\\clip(0,0,10,10)\\t(0,500,\\frz30)}文字');
assert.deepEqual(
commands.map((command) => [command.name, command.animated]),
[
['clip', false],
['t', false],
['frz', true],
],
);
assert.equal(hasAssTemporalOverride(commands), true);
});
test('collectAssOverrideCommands stops descending into deeply nested \\t tags', () => {
// Nested far past the recursion cap. Uncapped, this recurses once per level, and a
// pathological line (real files reach one or two levels) overflows the stack.
const nesting = 32;
const block = `{${'\\t(0,500,'.repeat(nesting)}\\frz30${')'.repeat(nesting)}}文字`;
const commands = collectAssOverrideCommands(block);
// The outer `\t` plus one per allowed recursion level, and nothing from below the cap.
assert.equal(commands.length, 9);
assert.deepEqual(new Set(commands.map((command) => command.name)), new Set(['t']));
assert.equal(hasAssTemporalOverride(commands), true);
});
test('hasAssTemporalOverride ignores static placement and shape tags', () => {
assert.equal(
hasAssTemporalOverride(collectAssOverrideCommands('{\\pos(1,2)\\clip(m 1 1)\\blur2}文字')),
false,
);
assert.equal(hasAssTemporalOverride(collectAssOverrideCommands('{\\move(1,2,3,4)}文字')), true);
});
test('isAssTemporalCommand covers only intrinsically animated tags', () => {
for (const command of ['t', 'move', 'k', 'kf', 'ko', 'K']) {
assert.equal(isAssTemporalCommand(command), true, command);
}
for (const command of ['clip', 'iclip', 'frz', 'fscx', 'blur', 'be', 'pos', 'fad']) {
assert.equal(isAssTemporalCommand(command), false, command);
}
});
test('assOverrideSignature distinguishes events by their override values', () => {
const first = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 1 1)}歌詞'));
const second = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 2 2)}歌詞'));
const repeat = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 1 1)}別の行'));
assert.notEqual(first, second);
assert.equal(first, repeat);
});
test('parseAssEffectField classifies the event-level Effect column', () => {
assert.equal(parseAssEffectField(''), 'none');
assert.equal(parseAssEffectField(' '), 'none');
assert.equal(parseAssEffectField('Banner;20;1;0'), 'banner');
assert.equal(parseAssEffectField('Scroll up;0;0;30;10'), 'scroll');
assert.equal(parseAssEffectField('Scroll down;0;0;30;10'), 'scroll');
assert.equal(parseAssEffectField('Karaoke'), 'karaoke');
assert.equal(parseAssEffectField('fx-template'), 'other');
});
test('parseAssEffectField matches stock effect names exactly', () => {
// Custom effect names that merely start with a stock name are not stock effects.
assert.equal(parseAssEffectField('scrolling-credit'), 'other');
assert.equal(parseAssEffectField('bannerfx;1'), 'other');
assert.equal(parseAssEffectField('karaoke-template'), 'other');
assert.equal(parseAssEffectField('Scroll'), 'other');
});
test('isAnimatedAssEffectKind covers the stock animated effects only', () => {
assert.equal(isAnimatedAssEffectKind('karaoke'), true);
assert.equal(isAnimatedAssEffectKind('banner'), true);
assert.equal(isAnimatedAssEffectKind('scroll'), true);
// Typesetting groups put static template names in this column too.
assert.equal(isAnimatedAssEffectKind('other'), false);
assert.equal(isAnimatedAssEffectKind('none'), false);
});
+280
View File
@@ -0,0 +1,280 @@
/*
* ASS/SSA text handling, split into two deliberately distinct contracts:
*
* assToPlainText() raw ASS event text -> plain text. Ingestion only.
* normalizePlainSubtitleText() already-decoded text -> display/lookup form.
*
* Subtitle text is decoded from ASS exactly once, at the point it enters the app: the
* file cue parser does it for sidecar/embedded scripts, and mpv does it for live text
* (`sub-text` is already run through mpv's own `ass_to_plaintext`). Everything
* downstream -- renderer, timing tracker, tokenizer, tokenization cache keys -- gets
* plain text and only normalizes whitespace, so no layer decodes the same string twice.
*
* assToPlainText mirrors mpv's `ass_to_plaintext` rather than inventing its own rules,
* so a cue parsed from a file reads the same as the same line arriving live:
* - `{...}` override blocks are markup
* - `\pN ... \p0` runs are vector paths, not dialogue
* - `\N`, `\n` and `\h` are the only escapes; `\{`, `\}` and `\\` are NOT escapes,
* so `\{注\}` decodes to a lone backslash exactly as mpv renders it
* - an unclosed `{` is rendered verbatim instead of swallowing the rest of the line
* Because the decoder never emits an escape or a closed brace, running it twice is a
* no-op -- but downstream code should still use normalizePlainSubtitleText.
*/
/** What `\N` and `\n` become. */
export type AssLineBreak = '\n' | ' ';
// `\p<n>` with n > 0 switches libass into vector-drawing mode: everything until the
// next `\p0` is a path (`m 20 0 b 10 0 ...`), not dialogue. The negative lookahead keeps
// `\pos(...)` from being read as a drawing tag.
const ASS_DRAWING_SCALE_PATTERN = /\\p(?![a-zA-Z])(\d*)/g;
function readDrawingScale(block: string): number | null {
ASS_DRAWING_SCALE_PATTERN.lastIndex = 0;
let scale: number | null = null;
let match: RegExpExecArray | null;
// Drawing mode is whatever the last `\p` tag in this block set it to.
while ((match = ASS_DRAWING_SCALE_PATTERN.exec(block)) !== null) {
scale = match[1] ? Number(match[1]) : 0;
}
return scale;
}
/** Resolve `\N`, `\n` and `\h`. The only text-level escapes libass recognises. */
function resolveWhitespaceEscapes(text: string, lineBreak: AssLineBreak): string {
return text.replace(/\\([Nnh])/g, (_match, escaped: string) =>
escaped === 'h' ? ' ' : lineBreak,
);
}
/** Strip `{...}` override blocks and the drawing runs they enable. */
function stripAssMarkup(raw: string): string {
let out = '';
let cursor = 0;
let drawing = false;
while (cursor < raw.length) {
if (raw[cursor] !== '{') {
if (!drawing) {
out += raw[cursor];
}
cursor += 1;
continue;
}
const close = raw.indexOf('}', cursor + 1);
if (close === -1) {
// mpv shows an unclosed `{` and everything after it. Guessing where the block was
// meant to end can eat a whole line of dialogue.
if (!drawing) {
out += raw.slice(cursor);
}
break;
}
const scale = readDrawingScale(raw.slice(cursor, close + 1));
if (scale !== null) {
drawing = scale > 0;
}
cursor = close + 1;
}
return out;
}
/**
* Decode a raw ASS/SSA event text field. Call this once, where the text enters the app;
* downstream layers take the result as plain text.
*/
export function assToPlainText(text: string, lineBreak: AssLineBreak = '\n'): string {
if (!text) return '';
return resolveWhitespaceEscapes(stripAssMarkup(text.replace(/\r\n/g, '\n')), lineBreak);
}
export interface NormalizePlainSubtitleTextOptions {
/** Fold every line break into a single space. */
collapseLineBreaks?: boolean;
trim?: boolean;
}
/**
* Whitespace normalization for text that has already been decoded -- by mpv for live
* subtitles, by the cue parser for files. Override blocks and drawing runs are none of
* this function's business; a `{` that reaches here is literal text mpv chose to show.
*
* `\N`/`\n`/`\h` are still folded, because subtitle sources outside the ASS path (asbplayer
* and other websocket clients) forward them raw and the display layer has to cope.
*/
export function normalizePlainSubtitleText(
text: string,
options: NormalizePlainSubtitleTextOptions = {},
): string {
if (!text) return '';
const { collapseLineBreaks = false, trim = true } = options;
let normalized = resolveWhitespaceEscapes(
text.replace(/\r\n/g, '\n'),
collapseLineBreaks ? ' ' : '\n',
);
if (collapseLineBreaks) {
normalized = normalized.replace(/\n/g, ' ').replace(/\s+/g, ' ');
}
return trim ? normalized.trim() : normalized;
}
/** The contents of each `{...}` block, without the braces. */
export function extractAssOverrideBlocks(text: string): string[] {
const blocks: string[] = [];
let cursor = 0;
while (cursor < text.length) {
const open = text.indexOf('{', cursor);
if (open === -1) {
break;
}
const close = text.indexOf('}', open + 1);
if (close === -1) {
break;
}
blocks.push(text.slice(open + 1, close));
cursor = close + 1;
}
return blocks;
}
export interface AssOverrideCommand {
/** Tag name without the backslash, e.g. `pos`, `kf`, `1c`. */
name: string;
/** Everything the tag was given, e.g. `960,1068` for `\pos(960,1068)`. */
args: string;
/** Nested inside a `\t(...)` argument, so its value is animated over the event. */
animated: boolean;
}
const ASS_OVERRIDE_NAME_PATTERN = /[1-4]?[a-zA-Z]+/y;
function readCommandArgs(block: string, start: number): { args: string; next: number } {
if (block[start] === '(') {
let depth = 0;
for (let i = start; i < block.length; i += 1) {
if (block[i] === '(') depth += 1;
else if (block[i] === ')') {
depth -= 1;
if (depth === 0) {
return { args: block.slice(start + 1, i), next: i + 1 };
}
}
}
return { args: block.slice(start + 1), next: block.length };
}
const nextTag = block.indexOf('\\', start);
const end = nextTag === -1 ? block.length : nextTag;
return { args: block.slice(start, end), next: end };
}
// `\t(...)` can wrap another `\t(...)`, and nothing in the format stops an author (or a
// malformed file) from nesting them thousands deep. Real typesetting never goes past one
// or two levels, so stop recursing well before the call stack is at risk.
const MAX_ANIMATION_NESTING_DEPTH = 8;
function parseOverrideBlock(
block: string,
animated: boolean,
into: AssOverrideCommand[],
depth = 0,
): void {
let cursor = 0;
while (cursor < block.length) {
if (block[cursor] !== '\\') {
cursor += 1;
continue;
}
ASS_OVERRIDE_NAME_PATTERN.lastIndex = cursor + 1;
const nameMatch = ASS_OVERRIDE_NAME_PATTERN.exec(block);
if (!nameMatch) {
cursor += 1;
continue;
}
const name = nameMatch[0];
const { args, next } = readCommandArgs(block, cursor + 1 + name.length);
into.push({ name, args: args.trim(), animated });
// `\t(0,500,\frz30)` animates whatever it wraps, so record the inner tags too.
if (name === 't' && args.includes('\\') && depth < MAX_ANIMATION_NESTING_DEPTH) {
parseOverrideBlock(args, true, into, depth + 1);
}
cursor = next;
}
}
/**
* Override commands with their arguments, in source order. Only `{...}` blocks are
* inspected, so a `\pos(...)` sitting in visible text is never mistaken for markup.
*/
export function collectAssOverrideCommands(text: string): AssOverrideCommand[] {
const commands: AssOverrideCommand[] = [];
for (const block of extractAssOverrideBlocks(text)) {
parseOverrideBlock(block, false, commands);
}
return commands;
}
// Tags that are animated by definition: `\t` interpolates, `\move` travels, and the
// karaoke tags advance a highlight across the event's own duration. Everything else --
// `\pos`, `\clip`, `\frz`, `\blur`, `\fad` -- is a static value for the event, so its
// presence says nothing about whether neighbouring events form one animation.
const ASS_TEMPORAL_COMMANDS = new Set(['t', 'move', 'k', 'kf', 'ko', 'K']);
export function isAssTemporalCommand(name: string): boolean {
return ASS_TEMPORAL_COMMANDS.has(name);
}
/** True when the event animates on its own, or animates a static tag through `\t(...)`. */
export function hasAssTemporalOverride(commands: readonly AssOverrideCommand[]): boolean {
return commands.some((command) => command.animated || isAssTemporalCommand(command.name));
}
/**
* Canonical form of an event's override values, for comparing consecutive events. Two
* events with the same signature were typeset identically, so neither is a frame of an
* animation the other belongs to.
*/
export function assOverrideSignature(commands: readonly AssOverrideCommand[]): string {
return commands.map((command) => `${command.name}(${command.args})`).join('|');
}
export type AssEffectKind = 'none' | 'banner' | 'scroll' | 'karaoke' | 'other';
// The stock effects, matched exactly. Typesetting groups put their own template names in
// this column -- `scrolling-credit` is a static sign, not libass's `Scroll up` -- so a
// prefix match would hand out animation evidence to arbitrary custom effects.
const STOCK_ASS_EFFECTS = new Map<string, AssEffectKind>([
['banner', 'banner'],
['scroll up', 'scroll'],
['scroll down', 'scroll'],
['karaoke', 'karaoke'],
]);
/**
* The event-level `Effect` column. The stock values (`Banner;...`, `Scroll up;...`,
* `Scroll down;...`, `Karaoke`) all animate; anything else is a custom name and lands in
* `other`.
*/
export function parseAssEffectField(raw: string): AssEffectKind {
const value = raw.trim().toLowerCase();
if (!value) return 'none';
const name = value.split(';', 1)[0]!.trim();
return STOCK_ASS_EFFECTS.get(name) ?? 'other';
}
const ANIMATED_ASS_EFFECT_KINDS = new Set<AssEffectKind>(['banner', 'scroll', 'karaoke']);
export function isAnimatedAssEffectKind(kind: AssEffectKind): boolean {
return ANIMATED_ASS_EFFECT_KINDS.has(kind);
}
+1
View File
@@ -85,6 +85,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'ankiConnect.fields.miscInfo', 'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel', 'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping', 'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.lapisKiku.wordCardKind',
] as const; ] as const;
function hotReloadFieldForChangedPath(path: string): string | null { function hotReloadFieldForChangedPath(path: string): string | null {
+5 -1
View File
@@ -1,5 +1,9 @@
export { Texthooker } from './texthooker'; export { Texthooker } from './texthooker';
export { hasMpvWebsocketPlugin, SubtitleWebSocket } from './subtitle-ws'; export {
hasMpvWebsocketPlugin,
isSubtitleAnnotationUpgrade,
SubtitleWebSocket,
} from './subtitle-ws';
export { registerGlobalShortcuts } from './shortcut'; export { registerGlobalShortcuts } from './shortcut';
export { createIpcDepsRuntime, registerIpcHandlers } from './ipc'; export { createIpcDepsRuntime, registerIpcHandlers } from './ipc';
export { shortcutMatchesInputForLocalFallback } from './shortcut-fallback'; export { shortcutMatchesInputForLocalFallback } from './shortcut-fallback';
+2 -2
View File
@@ -205,7 +205,7 @@ test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handl
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland']); assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland']);
}); });
test('runStartupBootstrapRuntime enables quiet background mode by default', () => { test('runStartupBootstrapRuntime lets config govern background log level by default', () => {
const calls: string[] = []; const calls: string[] = [];
const args = makeArgs({ background: true }); const args = makeArgs({ background: true });
@@ -222,7 +222,7 @@ test('runStartupBootstrapRuntime enables quiet background mode by default', () =
}); });
assert.equal(result.backgroundMode, true); assert.equal(result.backgroundMode, true);
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']); assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']);
}); });
test('runStartupBootstrapRuntime enables quiet update mode by default', () => { test('runStartupBootstrapRuntime enables quiet update mode by default', () => {
+1 -1
View File
@@ -45,7 +45,7 @@ export function runStartupBootstrapRuntime(
if (initialArgs.logLevel) { if (initialArgs.logLevel) {
deps.setLogLevel(initialArgs.logLevel, 'cli'); deps.setLogLevel(initialArgs.logLevel, 'cli');
} else if (initialArgs.background || initialArgs.update) { } else if (initialArgs.update) {
deps.setLogLevel('warn', 'cli'); deps.setLogLevel('warn', 'cli');
} }
@@ -11,7 +11,7 @@ import {
resolveSecondarySubtitleTextFromSidecar, resolveSecondarySubtitleTextFromSidecar,
} from '../secondary-subtitle-sidecar.js'; } from '../secondary-subtitle-sidecar.js';
import { import {
applyStatsWordAndSentenceCardFields, applyStatsWordCardFields,
createStatsMiningContext, createStatsMiningContext,
getStatsDirectMiningAudioFieldNames, getStatsDirectMiningAudioFieldNames,
getStatsWordMiningAudioFieldName, getStatsWordMiningAudioFieldName,
@@ -272,7 +272,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
const imageFieldName = ankiConfig.fields?.image ?? 'Picture'; const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
mediaFields[sentenceFieldName] = highlightedSentence; mediaFields[sentenceFieldName] = highlightedSentence;
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig); applyStatsWordCardFields(mediaFields, noteInfo, ankiConfig);
if (audioBuffer) { if (audioBuffer) {
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`; const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
@@ -1,5 +1,7 @@
import type { MediaGenerator } from '../../../media-generator.js'; import type { MediaGenerator } from '../../../media-generator.js';
import type { AnkiConnectConfig } from '../../../types.js'; import type { AnkiConnectConfig } from '../../../types.js';
import { applyCardKindFlagFields } from '../../../anki-integration/card-kinds.js';
import { resolveWordCardKind } from '../../../anki-integration/note-field-utils.js';
import { createLogger } from '../../../logger.js'; import { createLogger } from '../../../logger.js';
import type { RetimedSecondarySubtitleInput } from '../secondary-subtitle-sidecar.js'; import type { RetimedSecondarySubtitleInput } from '../secondary-subtitle-sidecar.js';
@@ -94,20 +96,22 @@ export function shouldUseStatsLapisKikuCardFields(ankiConfig: AnkiConnectConfig)
return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true; return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true;
} }
export function applyStatsWordAndSentenceCardFields( export function applyStatsWordCardFields(
fields: Record<string, string>, fields: Record<string, string>,
noteInfo: StatsServerNoteInfo | null, noteInfo: StatsServerNoteInfo | null,
ankiConfig: AnkiConnectConfig, ankiConfig: AnkiConnectConfig,
): void { ): void {
if (!shouldUseStatsLapisKikuCardFields(ankiConfig) || !noteInfo) return; if (!noteInfo) return;
const wordAndSentenceFlag = resolveStatsNoteFieldName(noteInfo, 'IsWordAndSentenceCard'); const cardKind = resolveWordCardKind(noteInfo, {
if (!wordAndSentenceFlag) return; lapisEnabled: ankiConfig.isLapis?.enabled === true,
kikuEnabled: ankiConfig.isKiku?.enabled === true,
wordCardKind: ankiConfig.lapisKiku?.wordCardKind,
});
if (!cardKind) return;
fields[wordAndSentenceFlag] = 'x'; applyCardKindFlagFields(fields, cardKind, (preferredName) =>
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) { resolveStatsNoteFieldName(noteInfo, preferredName),
const resolved = resolveStatsNoteFieldName(noteInfo, flagName); );
if (resolved && resolved !== wordAndSentenceFlag) fields[resolved] = '';
}
} }
export function getStatsDirectMiningAudioFieldNames( export function getStatsDirectMiningAudioFieldNames(
+397 -22
View File
@@ -8,6 +8,7 @@ import {
runSubsyncManual, runSubsyncManual,
triggerSubsyncFromConfig, triggerSubsyncFromConfig,
} from './subsync'; } from './subsync';
import type { SubsyncManualPayload } from '../../types';
function makeDeps( function makeDeps(
overrides: Partial<TriggerSubsyncFromConfigDeps> = {}, overrides: Partial<TriggerSubsyncFromConfigDeps> = {},
@@ -76,7 +77,7 @@ test('triggerSubsyncFromConfig opens manual picker', async () => {
await triggerSubsyncFromConfig( await triggerSubsyncFromConfig(
makeDeps({ makeDeps({
openManualPicker: (payload) => { openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length; payloadTrackCount = payload.subtitleTracks.length;
ffsubsyncAvailable = payload.ffsubsyncAvailable; ffsubsyncAvailable = payload.ffsubsyncAvailable;
}, },
showMpvOsd: (text) => { showMpvOsd: (text) => {
@@ -88,9 +89,9 @@ test('triggerSubsyncFromConfig opens manual picker', async () => {
}), }),
); );
assert.equal(payloadTrackCount, 1); assert.equal(payloadTrackCount, 2);
assert.equal(ffsubsyncAvailable, true); assert.equal(ffsubsyncAvailable, true);
assert.ok(osd.includes('Subsync: choose engine and source')); assert.ok(osd.includes('Subsync: choose engine and subtitles'));
assert.equal(inProgressState, false); assert.equal(inProgressState, false);
}); });
@@ -140,7 +141,7 @@ test('triggerSubsyncFromConfig does not run automatic sync', async () => {
await triggerSubsyncFromConfig( await triggerSubsyncFromConfig(
makeDeps({ makeDeps({
openManualPicker: (payload) => { openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length; payloadTrackCount = payload.subtitleTracks.length;
}, },
showMpvOsd: (text) => { showMpvOsd: (text) => {
osd.push(text); osd.push(text);
@@ -152,9 +153,9 @@ test('triggerSubsyncFromConfig does not run automatic sync', async () => {
}), }),
); );
assert.equal(payloadTrackCount, 1); assert.equal(payloadTrackCount, 2);
assert.equal(spinnerRan, false); assert.equal(spinnerRan, false);
assert.deepEqual(osd, ['Subsync: choose engine and source']); assert.deepEqual(osd, ['Subsync: choose engine and subtitles']);
}); });
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => { test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
@@ -195,12 +196,71 @@ test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async (
}, },
}), }),
openManualPicker: (payload) => { openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length; payloadTrackCount = payload.subtitleTracks.length;
}, },
}), }),
); );
assert.equal(payloadTrackCount, 1); assert.equal(payloadTrackCount, 2);
});
test('triggerSubsyncFromConfig keeps both active tracks when they share a file', async () => {
let payload: SubsyncManualPayload | null = null;
await triggerSubsyncFromConfig(
makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: () => {},
requestProperty: async (name: string) => {
if (name === 'path') return '/tmp/video.mkv';
if (name === 'sid') return 1;
if (name === 'secondary-sid') return 2;
if (name === 'track-list') {
// mpv appends a duplicate entry when the same file is re-added, so
// the primary and secondary slots can point at one path.
return [
{
id: 1,
type: 'sub',
selected: true,
external: true,
'external-filename': '/tmp/ref.srt',
},
{
id: 2,
type: 'sub',
selected: true,
external: true,
'external-filename': '/tmp/ref.srt',
},
{
id: 3,
type: 'sub',
selected: false,
external: true,
'external-filename': '/tmp/ref.srt',
},
];
}
return null;
},
}),
openManualPicker: (nextPayload) => {
payload = nextPayload;
},
}),
);
assert.ok(payload);
const resolved = payload as SubsyncManualPayload;
assert.deepEqual(
resolved.subtitleTracks.map((track) => track.id),
[1, 2],
);
assert.equal(resolved.defaultReferenceTrackId, 2);
assert.equal(resolved.defaultTargetTrackId, 1);
}); });
test('triggerSubsyncFromConfig reports failures to OSD', async () => { test('triggerSubsyncFromConfig reports failures to OSD', async () => {
@@ -217,15 +277,157 @@ test('triggerSubsyncFromConfig reports failures to OSD', async () => {
assert.ok(osd.some((line) => line.startsWith('Subsync failed: MPV not connected'))); assert.ok(osd.some((line) => line.startsWith('Subsync failed: MPV not connected')));
}); });
test('runSubsyncManual requires a source track for alass', async () => { test('runSubsyncManual requires a reference track for alass', async () => {
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: null }, makeDeps()); const result = await runSubsyncManual({ engine: 'alass', referenceTrackId: null }, makeDeps());
assert.deepEqual(result, { assert.deepEqual(result, {
ok: false, ok: false,
message: 'Select a subtitle source track for alass', message: 'Select a reference subtitle track for alass',
}); });
}); });
test('runSubsyncManual rejects alass when reference and target are the same track', async () => {
const result = await runSubsyncManual(
{ engine: 'alass', referenceTrackId: 2, targetTrackId: 2 },
makeDeps(),
);
assert.deepEqual(result, {
ok: false,
message: 'Reference and out-of-sync subtitles must be different tracks',
});
});
test('runSubsyncManual rejects an unknown target track', async () => {
const result = await runSubsyncManual(
{ engine: 'alass', referenceTrackId: 2, targetTrackId: 99 },
makeDeps(),
);
assert.deepEqual(result, {
ok: false,
message: 'Select the out-of-sync subtitle track to retime',
});
});
test('runSubsyncManual rejects the video reference for remote media', async () => {
const result = await runSubsyncManual(
{ engine: 'alass', referenceMode: 'video' },
makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: () => {},
requestProperty: async (name: string) => {
if (name === 'path') return 'https://jellyfin.example/Videos/movie/stream.mkv';
if (name === 'sid') return 1;
if (name === 'secondary-sid') return null;
if (name === 'track-list') {
return [{ id: 1, type: 'sub', selected: true, lang: 'jpn' }];
}
return null;
},
}),
}),
);
assert.equal(result.ok, false);
assert.match(result.message, /cannot use a stream URL as reference/);
});
test('openSubsyncManualPicker defaults the reference to the secondary subtitle track', async () => {
let payload: SubsyncManualPayload | null = null;
await triggerSubsyncFromConfig(
makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: () => {},
requestProperty: async (name: string) => {
if (name === 'path') return '/tmp/video.mkv';
if (name === 'sid') return 1;
if (name === 'secondary-sid') return 3;
if (name === 'track-list') {
return [
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
{
id: 2,
type: 'sub',
selected: false,
external: true,
lang: 'eng',
'external-filename': '/tmp/other.srt',
},
{
id: 3,
type: 'sub',
selected: true,
external: true,
lang: 'eng',
'external-filename': '/tmp/secondary.srt',
},
];
}
return null;
},
}),
openManualPicker: (nextPayload) => {
payload = nextPayload;
},
}),
);
assert.ok(payload);
const resolved = payload as SubsyncManualPayload;
assert.deepEqual(
resolved.subtitleTracks.map((track) => track.id),
[1, 2, 3],
);
assert.equal(resolved.defaultReferenceTrackId, 3);
assert.equal(resolved.defaultTargetTrackId, 1);
assert.equal(resolved.videoReferenceAvailable, true);
});
test('openSubsyncManualPicker never defaults to a reference missing from the track list', async () => {
let payload: SubsyncManualPayload | null = null;
await triggerSubsyncFromConfig(
makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: () => {},
requestProperty: async (name: string) => {
if (name === 'path') return '/tmp/video.mkv';
if (name === 'sid') return 1;
if (name === 'secondary-sid') return 2;
if (name === 'track-list') {
return [
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
// Secondary track with no usable file path: filtered out of the picker.
{ id: 2, type: 'sub', selected: true, external: true, 'external-filename': '' },
{ id: 3, type: 'sub', selected: false, lang: 'eng' },
];
}
return null;
},
}),
openManualPicker: (nextPayload) => {
payload = nextPayload;
},
}),
);
assert.ok(payload);
const resolved = payload as SubsyncManualPayload;
assert.deepEqual(
resolved.subtitleTracks.map((track) => track.id),
[1, 3],
);
assert.equal(resolved.defaultReferenceTrackId, 3);
});
test('triggerSubsyncFromConfig does not validate sync tool paths before manual selection', async () => { test('triggerSubsyncFromConfig does not validate sync tool paths before manual selection', async () => {
const osd: string[] = []; const osd: string[] = [];
const inProgress: boolean[] = []; const inProgress: boolean[] = [];
@@ -242,7 +444,7 @@ test('triggerSubsyncFromConfig does not validate sync tool paths before manual s
inProgress.push(value); inProgress.push(value);
}, },
openManualPicker: (payload) => { openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length; payloadTrackCount = payload.subtitleTracks.length;
}, },
showMpvOsd: (text) => { showMpvOsd: (text) => {
osd.push(text); osd.push(text);
@@ -251,8 +453,8 @@ test('triggerSubsyncFromConfig does not validate sync tool paths before manual s
); );
assert.deepEqual(inProgress, [false]); assert.deepEqual(inProgress, [false]);
assert.equal(payloadTrackCount, 1); assert.equal(payloadTrackCount, 2);
assert.deepEqual(osd, ['Subsync: choose engine and source']); assert.deepEqual(osd, ['Subsync: choose engine and subtitles']);
}); });
function writeExecutableScript(filePath: string, content: string): void { function writeExecutableScript(filePath: string, content: string): void {
@@ -333,7 +535,7 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
}), }),
}); });
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps); const result = await runSubsyncManual({ engine: 'ffsubsync' }, deps);
assert.equal(result.ok, true); assert.equal(result.ok, true);
assert.equal(result.message, 'Subtitle synchronized with ffsubsync'); assert.equal(result.message, 'Subtitle synchronized with ffsubsync');
@@ -346,7 +548,7 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
const ffOutputFlagIndex = ffArgs.indexOf('-o'); const ffOutputFlagIndex = ffArgs.indexOf('-o');
assert.equal(ffOutputFlagIndex >= 0, true); assert.equal(ffOutputFlagIndex >= 0, true);
assert.equal(ffArgs[ffOutputFlagIndex + 1], toShellPath(primaryPath)); assert.equal(ffArgs[ffOutputFlagIndex + 1], toShellPath(primaryPath));
assert.equal(sentCommands[0]?.[0], 'sub_add'); assert.equal(sentCommands[0]?.[0], 'sub-add');
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]); assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
}); });
@@ -399,7 +601,7 @@ test('runSubsyncManual writes deterministic _retimed filename when replace is fa
}), }),
}); });
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps); const result = await runSubsyncManual({ engine: 'ffsubsync' }, deps);
assert.equal(result.ok, true); assert.equal(result.ok, true);
const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n'); const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n');
@@ -453,7 +655,7 @@ test('runSubsyncManual reports ffsubsync command failures with details', async (
}), }),
}); });
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps); const result = await runSubsyncManual({ engine: 'ffsubsync' }, deps);
assert.equal(result.ok, false); assert.equal(result.ok, false);
assert.equal(result.message.startsWith('ffsubsync synchronization failed'), true); assert.equal(result.message.startsWith('ffsubsync synchronization failed'), true);
@@ -518,7 +720,7 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
}), }),
}); });
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 2 }, deps); const result = await runSubsyncManual({ engine: 'alass', referenceTrackId: 2 }, deps);
assert.equal(result.ok, false); assert.equal(result.ok, false);
assert.equal(typeof result.message, 'string'); assert.equal(typeof result.message, 'string');
@@ -528,6 +730,179 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
assert.equal(alassArgs[1], toShellPath(primaryPath)); assert.equal(alassArgs[1], toShellPath(primaryPath));
}); });
function makeAlassSelectionDeps(tmpDir: string): {
deps: TriggerSubsyncFromConfigDeps;
alassLogPath: string;
videoPath: string;
primaryPath: string;
sourcePath: string;
sentCommands: Array<Array<string | number>>;
} {
const alassLogPath = path.join(tmpDir, 'alass-args.log');
const alassPath = path.join(tmpDir, 'alass.sh');
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
const videoPath = path.join(tmpDir, 'video.mkv');
const primaryPath = path.join(tmpDir, 'primary.srt');
const sourcePath = path.join(tmpDir, 'source.srt');
fs.writeFileSync(videoPath, 'video');
fs.writeFileSync(primaryPath, 'sub');
fs.writeFileSync(sourcePath, 'sub2');
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(
alassPath,
`#!/bin/sh\n: > "${toShellPath(alassLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(alassLogPath)}"; done\n: > "$3"\nexit 0\n`,
);
const trackList: Array<Record<string, unknown>> = [
{ id: 1, type: 'sub', selected: true, external: true, 'external-filename': primaryPath },
{ id: 2, type: 'sub', selected: true, external: true, 'external-filename': sourcePath },
];
const sentCommands: Array<Array<string | number>> = [];
const deps = makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: (payload) => {
sentCommands.push(payload.command);
if (payload.command[0] === 'sub-add' || payload.command[0] === 'sub_add') {
trackList.push({
id: trackList.length + 1,
type: 'sub',
selected: false,
external: true,
'external-filename': payload.command[1],
});
}
},
requestProperty: async (name: string) => {
if (name === 'path') return videoPath;
if (name === 'sid') return 1;
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return trackList;
return null;
},
}),
getResolvedConfig: () => ({
alassPath,
ffsubsyncPath,
ffmpegPath,
replace: false,
}),
});
return { deps, alassLogPath, videoPath, primaryPath, sourcePath, sentCommands };
}
test('runSubsyncManual uses the video file as alass reference when requested', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-video-ref-'));
const { deps, alassLogPath, videoPath, primaryPath } = makeAlassSelectionDeps(tmpDir);
const result = await runSubsyncManual({ engine: 'alass', referenceMode: 'video' }, deps);
assert.equal(result.ok, true);
const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n');
assert.equal(alassArgs[0], toShellPath(videoPath));
assert.equal(alassArgs[1], toShellPath(primaryPath));
});
test('runSubsyncManual retimes the selected target track instead of the primary', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-target-'));
const { deps, alassLogPath, primaryPath, sourcePath, sentCommands } =
makeAlassSelectionDeps(tmpDir);
const result = await runSubsyncManual(
{ engine: 'alass', referenceTrackId: 1, targetTrackId: 2 },
deps,
);
assert.equal(result.ok, true);
const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n');
assert.equal(alassArgs[0], toShellPath(primaryPath));
assert.equal(alassArgs[1], toShellPath(sourcePath));
assert.equal(sentCommands[0]?.[0], 'sub-add');
assert.equal(sentCommands[0]?.[2], 'auto');
});
test('runSubsyncManual keeps a retimed secondary track in the secondary slot', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-secondary-slot-'));
const alassPath = path.join(tmpDir, 'alass.sh');
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
const videoPath = path.join(tmpDir, 'video.mkv');
const primaryPath = path.join(tmpDir, 'ja.srt');
const secondaryPath = path.join(tmpDir, 'en.srt');
const retimedPath = path.join(tmpDir, 'en_retimed.srt');
fs.writeFileSync(videoPath, 'video');
fs.writeFileSync(primaryPath, 'ja');
fs.writeFileSync(secondaryPath, 'en');
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(alassPath, '#!/bin/sh\n: > "$3"\nexit 0\n');
const trackList: Array<Record<string, unknown>> = [
{ id: 1, type: 'sub', selected: true, external: true, 'external-filename': primaryPath },
{ id: 2, type: 'sub', selected: true, external: true, 'external-filename': secondaryPath },
];
const sentCommands: Array<Array<string | number>> = [];
const deps = makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: (payload) => {
sentCommands.push(payload.command);
if (payload.command[0] === 'sub-add') {
trackList.push({
id: 3,
type: 'sub',
selected: false,
external: true,
'external-filename': payload.command[1],
});
}
},
requestProperty: async (name: string) => {
if (name === 'path') return videoPath;
if (name === 'sid') return 1;
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return trackList;
return null;
},
}),
getResolvedConfig: () => ({
alassPath,
ffsubsyncPath,
ffmpegPath,
replace: false,
}),
});
const result = await runSubsyncManual(
{ engine: 'alass', referenceTrackId: 1, targetTrackId: 2 },
deps,
);
assert.equal(result.ok, true);
assert.deepEqual(sentCommands[0], ['sub-add', retimedPath, 'auto']);
assert.deepEqual(sentCommands[1], ['set_property', 'secondary-sub-delay', 0]);
assert.deepEqual(sentCommands[2], ['set_property', 'secondary-sid', 3]);
assert.equal(
sentCommands.some((command) => command[1] === 'sub-delay'),
false,
);
assert.equal(
sentCommands.some((command) => command[1] === 'sid'),
false,
);
assert.equal(
sentCommands.some((command) => command[1] === 'sid'),
false,
);
});
test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => { test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-internal-source-')); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-internal-source-'));
const alassPath = path.join(tmpDir, 'alass.sh'); const alassPath = path.join(tmpDir, 'alass.sh');
@@ -589,11 +964,11 @@ test('runSubsyncManual keeps internal alass source file alive until sync finishe
}), }),
}); });
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 2 }, deps); const result = await runSubsyncManual({ engine: 'alass', referenceTrackId: 2 }, deps);
assert.equal(result.ok, true); assert.equal(result.ok, true);
assert.equal(result.message, 'Subtitle synchronized with alass'); assert.equal(result.message, 'Subtitle synchronized with alass');
assert.equal(sentCommands[0]?.[0], 'sub_add'); assert.equal(sentCommands[0]?.[0], 'sub-add');
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]); assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
}); });
@@ -645,7 +1020,7 @@ test('runSubsyncManual resolves string sid values from mpv stream properties', a
}), }),
}); });
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps); const result = await runSubsyncManual({ engine: 'ffsubsync' }, deps);
assert.equal(result.ok, true); assert.equal(result.ok, true);
assert.equal(result.message, 'Subtitle synchronized with ffsubsync'); assert.equal(result.message, 'Subtitle synchronized with ffsubsync');
+195 -34
View File
@@ -21,6 +21,11 @@ interface FileExtractionResult {
temporary: boolean; temporary: boolean;
} }
type SubtitleSlot = 'primary' | 'secondary';
const SYNCED_TRACK_LOOKUP_ATTEMPTS = 5;
const SYNCED_TRACK_LOOKUP_RETRY_MS = 100;
function summarizeCommandFailure(command: string, result: CommandResult): string { function summarizeCommandFailure(command: string, result: CommandResult): string {
const parts = [ const parts = [
`code=${result.code ?? 'n/a'}`, `code=${result.code ?? 'n/a'}`,
@@ -90,16 +95,28 @@ function getSourceTrackIdentity(track: MpvTrack): string {
return 'unknown'; return 'unknown';
} }
function dedupeSourceTracks(tracks: MpvTrack[]): MpvTrack[] { function isPinned(track: MpvTrack, pinnedIds: Set<number>): boolean {
const deduped = new Map<string, MpvTrack>(); return typeof track.id === 'number' && pinnedIds.has(track.id);
}
// Pinned tracks (the active primary/secondary) always survive, even when two of
// them point at the same file; only unpinned duplicates are collapsed.
function dedupeSubtitleTracks(tracks: MpvTrack[], pinnedIds: Set<number>): MpvTrack[] {
const pinnedIdentities = new Set(
tracks.filter((track) => isPinned(track, pinnedIds)).map(getSourceTrackIdentity),
);
const winners = new Map<string, MpvTrack>();
for (const track of tracks) { for (const track of tracks) {
if (isPinned(track, pinnedIds)) continue;
const identity = getSourceTrackIdentity(track); const identity = getSourceTrackIdentity(track);
const existing = deduped.get(identity); if (pinnedIdentities.has(identity)) continue;
const existing = winners.get(identity);
if (!existing || (track.selected && !existing.selected)) { if (!existing || (track.selected && !existing.selected)) {
deduped.set(identity, track); winners.set(identity, track);
} }
} }
return [...deduped.values()]; const kept = new Set(winners.values());
return tracks.filter((track) => isPinned(track, pinnedIds) || kept.has(track));
} }
export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps { export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps {
@@ -142,20 +159,21 @@ async function gatherSubsyncContext(client: MpvClientLike): Promise<SubsyncConte
} }
const secondaryTrack = subtitleTracks.find((track) => track.id === secondarySid) ?? null; const secondaryTrack = subtitleTracks.find((track) => track.id === secondarySid) ?? null;
const sourceTracks = subtitleTracks const usableTracks = subtitleTracks.filter((track) => {
.filter((track) => track.id !== sid) if (typeof track.id !== 'number') return false;
.filter((track) => {
if (!track.external) return true; if (!track.external) return true;
const filename = track['external-filename']; const filename = track['external-filename'];
return typeof filename === 'string' && filename.length > 0; return typeof filename === 'string' && filename.length > 0;
}); });
const uniqueSourceTracks = dedupeSourceTracks(sourceTracks);
return { return {
videoPath, videoPath,
primaryTrack, primaryTrack,
secondaryTrack, secondaryTrack,
sourceTracks: uniqueSourceTracks, subtitleTracks: dedupeSubtitleTracks(
usableTracks,
new Set([sid, secondarySid].filter((id): id is number => typeof id === 'number')),
),
audioStreamIndex: client.currentAudioStreamIndex, audioStreamIndex: client.currentAudioStreamIndex,
}; };
} }
@@ -271,41 +289,104 @@ async function runFfsubsyncSync(
return runCommand(ffsubsyncPath, args); return runCommand(ffsubsyncPath, args);
} }
function loadSyncedSubtitle(client: MpvClientLike, pathToLoad: string): void { function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// mpv may echo the path back with different separators, and Windows paths are
// case-insensitive, so compare normalized forms instead of raw strings.
function normalizeSubtitlePathForCompare(value: string): string {
const normalized = value.replace(/\\/g, '/');
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
async function findAddedSubtitleTrackId(
client: MpvClientLike,
pathToLoad: string,
): Promise<number | null> {
const wanted = normalizeSubtitlePathForCompare(pathToLoad);
// sub-add is queued, so the track may not appear in the first track-list reply.
for (let attempt = 0; attempt < SYNCED_TRACK_LOOKUP_ATTEMPTS; attempt += 1) {
let tracks: MpvTrack[] = [];
try {
const trackListRaw = await client.requestProperty('track-list');
tracks = Array.isArray(trackListRaw) ? normalizeTrackIds(trackListRaw as MpvTrack[]) : [];
} catch {
return null;
}
// Re-adding a file mpv already knows appends a duplicate entry; the newest
// one holds the retimed content, so prefer the last match.
const matches = tracks.filter((track) => {
if (track.type !== 'sub') return false;
const filename = track['external-filename'];
return typeof filename === 'string' && normalizeSubtitlePathForCompare(filename) === wanted;
});
const added = matches[matches.length - 1];
if (added && typeof added.id === 'number') {
return added.id;
}
if (attempt < SYNCED_TRACK_LOOKUP_ATTEMPTS - 1) {
await delay(SYNCED_TRACK_LOOKUP_RETRY_MS);
}
}
return null;
}
async function loadSyncedSubtitle(
client: MpvClientLike,
pathToLoad: string,
slot: SubtitleSlot,
): Promise<void> {
if (!client.connected) { if (!client.connected) {
throw new Error('MPV disconnected while loading subtitle'); throw new Error('MPV disconnected while loading subtitle');
} }
client.send({ command: ['sub_add', pathToLoad] });
if (slot === 'secondary') {
// Keep the primary track untouched: load without selecting, then point
// secondary-sid at the freshly added track.
client.send({ command: ['sub-add', pathToLoad, 'auto'] });
client.send({ command: ['set_property', 'secondary-sub-delay', 0] });
const addedTrackId = await findAddedSubtitleTrackId(client, pathToLoad);
if (addedTrackId === null) {
throw new Error('Synchronized subtitle did not appear in the mpv track list');
}
client.send({ command: ['set_property', 'secondary-sid', addedTrackId] });
return;
}
client.send({ command: ['sub-add', pathToLoad] });
client.send({ command: ['set_property', 'sub-delay', 0] }); client.send({ command: ['set_property', 'sub-delay', 0] });
} }
async function subsyncToReference( async function subsyncToReference(
engine: 'alass' | 'ffsubsync', engine: 'alass' | 'ffsubsync',
referenceFilePath: string, referenceFilePath: string,
targetTrack: MpvTrack,
context: SubsyncContext, context: SubsyncContext,
resolved: SubsyncResolvedConfig, resolved: SubsyncResolvedConfig,
client: MpvClientLike, client: MpvClientLike,
slot: SubtitleSlot,
): Promise<SubsyncResult> { ): Promise<SubsyncResult> {
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg'); const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
const primaryExtraction = await extractSubtitleTrackToFile( const targetExtraction = await extractSubtitleTrackToFile(
ffmpegPath, ffmpegPath,
context.videoPath, context.videoPath,
context.primaryTrack, targetTrack,
); );
const replacePrimary = resolved.replace !== false && !primaryExtraction.temporary; const replaceTarget = resolved.replace !== false && !targetExtraction.temporary;
const outputPath = buildRetimedPath(primaryExtraction.path, replacePrimary); const outputPath = buildRetimedPath(targetExtraction.path, replaceTarget);
try { try {
let result: CommandResult; let result: CommandResult;
if (engine === 'alass') { if (engine === 'alass') {
const alassPath = ensureExecutablePath(resolved.alassPath, 'alass'); const alassPath = ensureExecutablePath(resolved.alassPath, 'alass');
result = await runAlassSync(alassPath, referenceFilePath, primaryExtraction.path, outputPath); result = await runAlassSync(alassPath, referenceFilePath, targetExtraction.path, outputPath);
} else { } else {
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync'); const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
result = await runFfsubsyncSync( result = await runFfsubsyncSync(
ffsubsyncPath, ffsubsyncPath,
context.videoPath, context.videoPath,
primaryExtraction.path, targetExtraction.path,
outputPath, outputPath,
context.audioStreamIndex, context.audioStreamIndex,
); );
@@ -319,13 +400,13 @@ async function subsyncToReference(
}; };
} }
loadSyncedSubtitle(client, outputPath); await loadSyncedSubtitle(client, outputPath, slot);
return { return {
ok: true, ok: true,
message: `Subtitle synchronized with ${engine}`, message: `Subtitle synchronized with ${engine}`,
}; };
} finally { } finally {
cleanupTemporaryFile(primaryExtraction); cleanupTemporaryFile(targetExtraction);
} }
} }
@@ -337,6 +418,25 @@ function validateFfsubsyncReference(videoPath: string): void {
} }
} }
function resolveTargetTrack(
request: SubsyncManualRunRequest,
context: SubsyncContext,
): MpvTrack | null {
if (request.targetTrackId === undefined || request.targetTrackId === null) {
return context.primaryTrack;
}
return getTrackById(context.subtitleTracks, request.targetTrackId);
}
// Retiming the secondary track must not steal the primary slot: the synced file
// goes back where the out-of-sync one was.
function resolveTargetSlot(targetTrack: MpvTrack, context: SubsyncContext): SubtitleSlot {
if (typeof targetTrack.id !== 'number') return 'primary';
if (targetTrack.id === context.primaryTrack.id) return 'primary';
if (context.secondaryTrack && targetTrack.id === context.secondaryTrack.id) return 'secondary';
return 'primary';
}
export async function runSubsyncManual( export async function runSubsyncManual(
request: SubsyncManualRunRequest, request: SubsyncManualRunRequest,
deps: SubsyncCoreDeps, deps: SubsyncCoreDeps,
@@ -345,6 +445,12 @@ export async function runSubsyncManual(
const context = await gatherSubsyncContext(client); const context = await gatherSubsyncContext(client);
const resolved = deps.getResolvedConfig(); const resolved = deps.getResolvedConfig();
const targetTrack = resolveTargetTrack(request, context);
if (!targetTrack) {
return { ok: false, message: 'Select the out-of-sync subtitle track to retime' };
}
const targetSlot = resolveTargetSlot(targetTrack, context);
if (request.engine === 'ffsubsync') { if (request.engine === 'ffsubsync') {
try { try {
validateFfsubsyncReference(context.videoPath); validateFfsubsyncReference(context.videoPath);
@@ -354,22 +460,64 @@ export async function runSubsyncManual(
message: `ffsubsync synchronization failed: ${(error as Error).message}`, message: `ffsubsync synchronization failed: ${(error as Error).message}`,
}; };
} }
return subsyncToReference('ffsubsync', context.videoPath, context, resolved, client); return subsyncToReference(
'ffsubsync',
context.videoPath,
targetTrack,
context,
resolved,
client,
targetSlot,
);
} }
const sourceTrack = getTrackById(context.sourceTracks, request.sourceTrackId ?? null); if (request.referenceMode === 'video') {
if (!sourceTrack) { if (isRemoteMediaPath(context.videoPath)) {
return { ok: false, message: 'Select a subtitle source track for alass' }; return {
ok: false,
message:
'alass cannot use a stream URL as reference. Pick a reference subtitle track instead.',
};
}
return subsyncToReference(
'alass',
context.videoPath,
targetTrack,
context,
resolved,
client,
targetSlot,
);
}
const referenceTrack = getTrackById(context.subtitleTracks, request.referenceTrackId ?? null);
if (!referenceTrack) {
return { ok: false, message: 'Select a reference subtitle track for alass' };
}
if (referenceTrack.id === targetTrack.id) {
return { ok: false, message: 'Reference and out-of-sync subtitles must be different tracks' };
} }
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg'); const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
let sourceExtraction: FileExtractionResult | null = null; let referenceExtraction: FileExtractionResult | null = null;
try { try {
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack); referenceExtraction = await extractSubtitleTrackToFile(
return await subsyncToReference('alass', sourceExtraction.path, context, resolved, client); ffmpegPath,
context.videoPath,
referenceTrack,
);
return await subsyncToReference(
'alass',
referenceExtraction.path,
targetTrack,
context,
resolved,
client,
targetSlot,
);
} finally { } finally {
if (sourceExtraction) { if (referenceExtraction) {
cleanupTemporaryFile(sourceExtraction); cleanupTemporaryFile(referenceExtraction);
} }
} }
} }
@@ -377,14 +525,27 @@ export async function runSubsyncManual(
export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps): Promise<void> { export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps): Promise<void> {
const client = getMpvClientForSubsync(deps); const client = getMpvClientForSubsync(deps);
const context = await gatherSubsyncContext(client); const context = await gatherSubsyncContext(client);
const payload: SubsyncManualPayload = { const subtitleTracks = context.subtitleTracks
ffsubsyncAvailable: !isRemoteMediaPath(context.videoPath),
sourceTracks: context.sourceTracks
.filter((track) => typeof track.id === 'number') .filter((track) => typeof track.id === 'number')
.map((track) => ({ .map((track) => ({
id: track.id as number, id: track.id as number,
label: formatTrackLabel(track), label: formatTrackLabel(track),
})), }));
const primaryTrackId =
typeof context.primaryTrack.id === 'number' ? context.primaryTrack.id : null;
const secondaryTrackId =
typeof context.secondaryTrack?.id === 'number' ? context.secondaryTrack.id : null;
const payload: SubsyncManualPayload = {
subtitleTracks,
// The secondary track can be filtered or deduped out of the emitted list,
// so only default to it when the picker actually offers it.
defaultReferenceTrackId:
subtitleTracks.find((track) => track.id === secondaryTrackId)?.id ??
subtitleTracks.find((track) => track.id !== primaryTrackId)?.id ??
null,
defaultTargetTrackId: primaryTrackId,
videoReferenceAvailable: !isRemoteMediaPath(context.videoPath),
ffsubsyncAvailable: !isRemoteMediaPath(context.videoPath),
}; };
deps.openManualPicker(payload); deps.openManualPicker(payload);
} }
@@ -397,7 +558,7 @@ export async function triggerSubsyncFromConfig(deps: TriggerSubsyncFromConfigDep
try { try {
await openSubsyncManualPicker(deps); await openSubsyncManualPicker(deps);
deps.showMpvOsd('Subsync: choose engine and source'); deps.showMpvOsd('Subsync: choose engine and subtitles');
} catch (error) { } catch (error) {
deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`); deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`);
} finally { } finally {
+191
View File
@@ -0,0 +1,191 @@
/*
* Duplicate/animation-burst collapsing for parsed subtitle cues.
*
* Split out of the cue parser so the parsing rules and the "is this run one animation?"
* heuristics can be read -- and tested -- on their own. The parser owns the cue shape;
* this module only decides which cues survive.
*/
import { hasAssTemporalOverride, isAnimatedAssEffectKind } from './ass-text';
import type {
AnnotatedSubtitleCue,
SubtitleCue,
SubtitleSourceFormat,
} from './subtitle-cue-parser';
// Back-to-back frames of the same animation are authored flush against each other; a
// tiny tolerance absorbs the centisecond rounding of the ASS timestamp format.
const DUPLICATE_CUE_GAP_TOLERANCE_SECONDS = 0.05;
// A burst is a *sequence*. Two adjacent events are two events, not an animation --
// characters do repeat each other, and a repeated line can legitimately be short.
const MIN_BURST_EVENTS = 3;
// Real dialogue holds on screen for about a second, so a run with a couple of much
// shorter events among them looks like frames. Used only alongside authoring evidence.
const ANIMATION_FRAME_MAX_SECONDS = 0.3;
// A karaoke run usually ends on a long "hold" frame, so not every event is short.
const MIN_TAGGED_BURST_FRAMES = 2;
// SRT and VTT carry no authoring metadata at all, so timing is the only signal available
// -- which makes it the easiest one to get wrong. ASS->SRT conversion leaves frames at
// ~0.04s, well under any real utterance, and a burst leaves many of them behind. Both
// bounds are deliberately far stricter than the ASS path: a run of ordinary short lines
// (`えっ` traded between characters) must not clear them.
const TIMING_ONLY_FRAME_MAX_SECONDS = 0.1;
const MIN_TIMING_ONLY_FRAMES = 5;
function cueKey(cue: SubtitleCue): string {
return `${cue.startTime}|${cue.endTime}|${cue.text}`;
}
/**
* Identical text over an identical span is redundant however it was authored -- most
* often a layered ASS event stacking a shadow copy under the visible one.
*/
function collapseExactDuplicates(cues: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
const seen = new Set<string>();
return cues.filter((cue) => {
const key = cueKey(cue);
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function countFramesShorterThan(run: AnnotatedSubtitleCue[], maxSeconds: number): number {
return run.filter((cue) => cue.endTime - cue.startTime < maxSeconds).length;
}
/**
* Evidence that a run of ASS events is one animation rather than several authored lines.
* A static tag says nothing on its own -- three events sharing one `\clip(...)` are three
* signs -- so the tag has to be temporal by nature (`\t`, `\move`, karaoke timing, or
* anything wrapped in `\t(...)`), an animated `Effect` column, or a value that actually
* changes from event to event, which is how per-frame typesetting is authored.
*/
export function hasAssAnimationEvidence(run: AnnotatedSubtitleCue[]): boolean {
if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) {
return true;
}
if (run.every((cue) => isAnimatedAssEffectKind(cue.effectKind))) {
return true;
}
const [first] = run;
const everyEventTypeset = run.every((cue) => cue.overrides.length > 0);
const signatureChanges = run.some((cue) => cue.overrideSignature !== first!.overrideSignature);
return everyEventTypeset && signatureChanges;
}
export function isAnimationBurst(
run: AnnotatedSubtitleCue[],
format: SubtitleSourceFormat,
): boolean {
if (run.length < MIN_BURST_EVENTS) {
return false;
}
if (format === 'srt') {
return (
run.length >= MIN_TIMING_ONLY_FRAMES &&
countFramesShorterThan(run, TIMING_ONLY_FRAME_MAX_SECONDS) === run.length
);
}
if (countFramesShorterThan(run, ANIMATION_FRAME_MAX_SECONDS) < MIN_TAGGED_BURST_FRAMES) {
return false;
}
// One animation belongs to one styled, one named source line. Two characters trading
// the same short word are two styles or two actors, and never merge.
const [first] = run;
if (run.some((cue) => cue.style !== first!.style || cue.name !== first!.name)) {
return false;
}
return hasAssAnimationEvidence(run);
}
/**
* Karaoke and sign typesetting emits one Dialogue event per animation frame, all carrying
* the same visible text over a contiguous span. Collapse each such run into a single cue.
*
* Only runs that look like animation collapse. Two ordinary lines that happen to repeat
* -- several characters each saying `おはよう` in turn, a positioned sign redrawn with a
* different fade -- stay separate, because merging them would destroy real mineable lines.
*/
function collapseAnimationBursts(
cues: AnnotatedSubtitleCue[],
format: SubtitleSourceFormat,
): AnnotatedSubtitleCue[] {
const indicesByText = new Map<string, number[]>();
cues.forEach((cue, index) => {
const bucket = indicesByText.get(cue.text);
if (bucket) {
bucket.push(index);
} else {
indicesByText.set(cue.text, [index]);
}
});
const dropped = new Set<number>();
const extendedEnd = new Map<number, number>();
for (const indices of indicesByText.values()) {
if (indices.length < MIN_BURST_EVENTS) {
continue;
}
let runStart = 0;
while (runStart < indices.length) {
let runEnd = runStart;
let chainEnd = cues[indices[runStart]!]!.endTime;
while (runEnd + 1 < indices.length) {
const next = cues[indices[runEnd + 1]!]!;
if (next.startTime > chainEnd + DUPLICATE_CUE_GAP_TOLERANCE_SECONDS) {
break;
}
chainEnd = Math.max(chainEnd, next.endTime);
runEnd += 1;
}
const run = indices.slice(runStart, runEnd + 1).map((index) => cues[index]!);
if (isAnimationBurst(run, format)) {
for (let i = runStart + 1; i <= runEnd; i += 1) {
dropped.add(indices[i]!);
}
extendedEnd.set(indices[runStart]!, chainEnd);
}
runStart = runEnd + 1;
}
}
if (dropped.size === 0) {
return cues;
}
const merged: AnnotatedSubtitleCue[] = [];
cues.forEach((cue, index) => {
if (dropped.has(index)) {
return;
}
const end = extendedEnd.get(index);
merged.push(end !== undefined && end > cue.endTime ? { ...cue, endTime: end } : cue);
});
return merged;
}
/**
* Collapse redundant cues. Input must already be sorted by non-decreasing `startTime`,
* ties broken by `endTime` then source `order` -- burst detection chains events by
* comparing each one against the running end of the events before it, so an unsorted
* list breaks runs apart and leaves the frames behind.
*/
export function mergeDuplicateCues(
cues: AnnotatedSubtitleCue[],
format: SubtitleSourceFormat,
): AnnotatedSubtitleCue[] {
return collapseAnimationBursts(collapseExactDuplicates(cues), format);
}
+393 -3
View File
@@ -91,6 +91,17 @@ test('parseSrtCues skips malformed timing lines gracefully', () => {
assert.equal(cues[0]!.text, '有効'); assert.equal(cues[0]!.text, '有効');
}); });
test('parseSubtitleCues strips complete brace blocks from SRT and VTT text', () => {
const content = ['1', '00:00:01,000 --> 00:00:02,000', '彼は{謎}と言った', ''].join('\n');
for (const filename of ['test.srt', 'test.vtt']) {
const cues = parseSubtitleCues(content, filename);
assert.equal(cues.length, 1, filename);
assert.equal(cues[0]!.text, '彼はと言った', filename);
}
});
test('parseAssCues parses basic ASS dialogue lines', () => { test('parseAssCues parses basic ASS dialogue lines', () => {
const content = [ const content = [
'[Script Info]', '[Script Info]',
@@ -137,7 +148,9 @@ test('parseAssCues handles text containing commas', () => {
assert.equal(cues[0]!.text, 'はい、そうです、ね'); assert.equal(cues[0]!.text, 'はい、そうです、ね');
}); });
test('parseAssCues handles \\N line breaks', () => { test('parseAssCues decodes \\N line breaks into real newlines', () => {
// ASS is decoded once, here at ingestion, so cue text matches what mpv hands over for
// the same line played live.
const content = [ const content = [
'[Events]', '[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
@@ -146,7 +159,7 @@ test('parseAssCues handles \\N line breaks', () => {
const cues = parseAssCues(content); const cues = parseAssCues(content);
assert.equal(cues[0]!.text, '一行目\\N二行目'); assert.equal(cues[0]!.text, '一行目\n二行目');
}); });
test('parseAssCues strips HTML-like markup while preserving ASS line breaks', () => { test('parseAssCues strips HTML-like markup while preserving ASS line breaks', () => {
@@ -158,7 +171,46 @@ test('parseAssCues strips HTML-like markup while preserving ASS line breaks', ()
const cues = parseAssCues(content); const cues = parseAssCues(content);
assert.equal(cues[0]!.text, '一行目\\N二行目'); assert.equal(cues[0]!.text, '一行目\n二行目');
});
test('parseAssCues drops vector drawing runs enabled by \\p', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\an5\\pos(730,1042)\\p1\\blur1}m 20 0 b 10 0 0 10 0 20 b 0 31 10 40 20 40 {\\p0}',
'Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,これは字幕',
].join('\n');
const cues = parseAssCues(content);
assert.equal(cues.length, 1);
assert.equal(cues[0]!.text, 'これは字幕');
});
test('parseAssCues keeps text that follows a \\p0 reset on the same line', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\p1}m 0 0 l 10 10{\\p0}本文{\\p1}m 5 5 l 6 6{\\p0}続き',
].join('\n');
const cues = parseAssCues(content);
assert.equal(cues.length, 1);
assert.equal(cues[0]!.text, '本文続き');
});
test('parseAssCues leaves \\pos untouched when no drawing mode is active', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(960,1068)\\bord3}位置指定',
].join('\n');
const cues = parseAssCues(content);
assert.equal(cues[0]!.text, '位置指定');
}); });
test('parseAssCues returns empty for content without Events section', () => { test('parseAssCues returns empty for content without Events section', () => {
@@ -258,6 +310,344 @@ test('parseSubtitleCues returns cues sorted by start time', () => {
assert.equal(cues[1]!.text, '二番目'); assert.equal(cues[1]!.text, '二番目');
}); });
test('parseSubtitleCues collapses per-frame karaoke duplicates into one cue', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.05,OP_JP,,0,0,0,,{\\clip(m 1 1)}過ぎ去ってしまう瞬間を',
'Dialogue: 0,0:00:01.05,0:00:01.09,OP_JP,,0,0,0,,{\\clip(m 2 2)}過ぎ去ってしまう瞬間を',
'Dialogue: 0,0:00:01.09,0:00:03.55,OP_JP,,0,0,0,,{\\clip(m 3 3)}過ぎ去ってしまう瞬間を',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 1);
assert.equal(cues[0]!.startTime, 1.0);
assert.equal(cues[0]!.endTime, 3.55);
assert.equal(cues[0]!.text, '過ぎ去ってしまう瞬間を');
});
test('parseSubtitleCues keeps back-to-back plain dialogue repeats separate', () => {
// Several characters greeting in turn: distinct utterances that happen to abut.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:04:05.67,0:04:06.82,Dial_JP,,0,0,0,,おはよう',
'Dialogue: 0,0:04:06.82,0:04:07.56,Dial_JP,,0,0,0,,おはよう',
'Dialogue: 0,0:04:07.56,0:04:08.78,Dial_JP,,0,0,0,,おはよう',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 3);
assert.equal(cues[0]!.endTime, 246.82);
assert.equal(cues[2]!.startTime, 247.56);
});
test('parseSubtitleCues collapses exact duplicate cues even without effect tags', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,重なった行',
'Dialogue: 1,0:00:01.00,0:00:04.00,Default,,0,0,0,,重なった行',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 1);
});
test('parseSubtitleCues collapses tag-less animation frames in converted SRT', () => {
// ASS -> SRT conversion drops override tags, so only the ~0.04s frame timing remains.
const lines = ['1', '00:00:07,870 --> 00:00:07,910', 'Kaguya Wants to be Confessed to', ''];
for (let i = 1; i < 8; i++) {
const start = 7910 + (i - 1) * 40;
const end = start + 40;
const at = (ms: number) =>
`00:00:0${Math.floor(ms / 1000)},${String(ms % 1000).padStart(3, '0')}`;
lines.push(String(i + 1), `${at(start)} --> ${at(end)}`, 'Kaguya Wants to be Confessed to', '');
}
const cues = parseSubtitleCues(lines.join('\n'), 'test.srt');
assert.equal(cues.length, 1);
assert.equal(cues[0]!.startTime, 7.87);
});
test('parseSubtitleCues keeps identical lines that recur far apart', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,なんで',
'Dialogue: 0,0:05:00.00,0:05:01.00,Default,,0,0,0,,なんで',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 2);
assert.equal(cues[0]!.startTime, 1.0);
assert.equal(cues[1]!.startTime, 300.0);
});
test('parseSubtitleCues keeps two positioned signs that repeat the same text', () => {
// Both carry override tags, but `\pos` and `\fad` are static placement, not animation.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:01:00.00,0:01:03.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(200,200)}第一話',
'Dialogue: 0,0:01:03.00,0:01:06.00,Sign,,0,0,0,,{\\pos(960,900)\\fad(200,200)}第一話',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 2);
assert.equal(cues[1]!.startTime, 63.0);
});
test('parseSubtitleCues keeps a run of ordinary positioned lines separate', () => {
// Three events is a sequence, but none of them runs at animation-frame speed.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:01:00.00,0:01:02.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
'Dialogue: 0,0:01:02.00,0:01:04.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
'Dialogue: 0,0:01:04.00,0:01:06.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 3);
});
test('parseSubtitleCues keeps a short repeated SRT pair without burst evidence', () => {
const content = [
'1',
'00:00:01,000 --> 00:00:01,200',
'えっ',
'',
'2',
'00:00:01,200 --> 00:00:01,400',
'えっ',
'',
].join('\n');
const cues = parseSubtitleCues(content, 'test.srt');
assert.equal(cues.length, 2);
});
test('parseSubtitleCues collapses a burst marked only by the Effect column', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.05,OP_JP,,0,0,0,Karaoke,歌詞',
'Dialogue: 0,0:00:01.05,0:00:01.09,OP_JP,,0,0,0,Karaoke,歌詞',
'Dialogue: 0,0:00:01.09,0:00:03.55,OP_JP,,0,0,0,Karaoke,歌詞',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 1);
assert.equal(cues[0]!.endTime, 3.55);
});
test('parseSubtitleCues keeps a second karaoke burst that starts after a gap', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.05,OP_JP,,0,0,0,,{\\clip(m 1 1)}リフレイン',
'Dialogue: 0,0:00:01.05,0:00:01.09,OP_JP,,0,0,0,,{\\clip(m 2 2)}リフレイン',
'Dialogue: 0,0:00:01.09,0:00:03.00,OP_JP,,0,0,0,,{\\clip(m 3 3)}リフレイン',
'Dialogue: 0,0:00:20.00,0:00:20.05,OP_JP,,0,0,0,,{\\clip(m 1 1)}リフレイン',
'Dialogue: 0,0:00:20.05,0:00:20.09,OP_JP,,0,0,0,,{\\clip(m 2 2)}リフレイン',
'Dialogue: 0,0:00:20.09,0:00:22.00,OP_JP,,0,0,0,,{\\clip(m 3 3)}リフレイン',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 2);
assert.equal(cues[0]!.endTime, 3.0);
assert.equal(cues[1]!.startTime, 20.0);
assert.equal(cues[1]!.endTime, 22.0);
});
test('parseSubtitleCues does not merge a burst into unrelated dialogue between frames', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.05,OP_JP,,0,0,0,,{\\clip(m 1 1)}歌詞',
'Dialogue: 0,0:00:01.02,0:00:03.00,Dial_JP,,0,0,0,,別のセリフ',
'Dialogue: 0,0:00:01.05,0:00:01.09,OP_JP,,0,0,0,,{\\clip(m 2 2)}歌詞',
'Dialogue: 0,0:00:01.09,0:00:03.55,OP_JP,,0,0,0,,{\\clip(m 3 3)}歌詞',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 2);
assert.deepEqual(
cues.map((cue) => cue.text),
['歌詞', '別のセリフ'],
);
assert.equal(cues[0]!.endTime, 3.55);
});
test('parseSubtitleCues keeps rapid ASS lines from different actors separate', () => {
// Three 200ms `えっ` reactions traded between characters. Fast, adjacent and identical,
// but authored as three lines: different styles and different actors.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.20,Dial_A,アリス,0,0,0,,えっ',
'Dialogue: 0,0:00:01.20,0:00:01.40,Dial_B,ボブ,0,0,0,,えっ',
'Dialogue: 0,0:00:01.40,0:00:01.60,Dial_C,キャロル,0,0,0,,えっ',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 3);
});
test('parseSubtitleCues reads the speaker column when it is spelled Actor', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Actor, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.20,Dial_JP,アリス,0,0,0,,えっ',
'Dialogue: 0,0:00:01.20,0:00:01.40,Dial_JP,ボブ,0,0,0,,えっ',
'Dialogue: 0,0:00:01.40,0:00:01.60,Dial_JP,キャロル,0,0,0,,えっ',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 3);
});
test('parseSubtitleCues does not treat a custom Effect name as animation', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.20,Sign,,0,0,0,scrolling-credit,制作',
'Dialogue: 0,0:00:01.20,0:00:01.40,Sign,,0,0,0,scrolling-credit,制作',
'Dialogue: 0,0:00:01.40,0:00:01.60,Sign,,0,0,0,scrolling-credit,制作',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 3);
});
test('parseSubtitleCues keeps rapid ASS lines that share a style but not an actor', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.20,Dial_JP,アリス,0,0,0,,えっ',
'Dialogue: 0,0:00:01.20,0:00:01.40,Dial_JP,ボブ,0,0,0,,えっ',
'Dialogue: 0,0:00:01.40,0:00:01.60,Dial_JP,キャロル,0,0,0,,えっ',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 3);
});
test('parseSubtitleCues keeps untagged rapid ASS repeats separate', () => {
// No overrides at all: timing-only evidence is an SRT/VTT fallback and must not apply
// to ASS, where the absence of typesetting is itself evidence of plain dialogue.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.05,Dial_JP,,0,0,0,,えっ',
'Dialogue: 0,0:00:01.05,0:00:01.10,Dial_JP,,0,0,0,,えっ',
'Dialogue: 0,0:00:01.10,0:00:01.15,Dial_JP,,0,0,0,,えっ',
'Dialogue: 0,0:00:01.15,0:00:01.20,Dial_JP,,0,0,0,,えっ',
'Dialogue: 0,0:00:01.20,0:00:01.25,Dial_JP,,0,0,0,,えっ',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 5);
});
test('parseSubtitleCues keeps repeated signs sharing one static clip', () => {
// `\clip` is a static shape for the event. Three events with the identical clip were
// typeset the same way, so none of them is a frame of the others.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.20,Sign,,0,0,0,,{\\clip(0,0,100,100)}注意',
'Dialogue: 0,0:00:01.20,0:00:01.40,Sign,,0,0,0,,{\\clip(0,0,100,100)}注意',
'Dialogue: 0,0:00:01.40,0:00:01.60,Sign,,0,0,0,,{\\clip(0,0,100,100)}注意',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 3);
});
test('parseSubtitleCues collapses a sign animated through \\t', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.20,Sign,,0,0,0,,{\\pos(10,10)\\t(0,200,\\frz30)}回る',
'Dialogue: 0,0:00:01.20,0:00:01.40,Sign,,0,0,0,,{\\pos(10,10)\\t(0,200,\\frz30)}回る',
'Dialogue: 0,0:00:01.40,0:00:03.00,Sign,,0,0,0,,{\\pos(10,10)\\t(0,200,\\frz30)}回る',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 1);
assert.equal(cues[0]!.endTime, 3.0);
});
test('parseSubtitleCues keeps a short repeated SRT run above the frame threshold', () => {
// Five contiguous 200ms cues: a sequence, but nowhere near animation-frame speed.
const lines: string[] = [];
for (let i = 0; i < 5; i++) {
const start = 1000 + i * 200;
const at = (ms: number) =>
`00:00:0${Math.floor(ms / 1000)},${String(ms % 1000).padStart(3, '0')}`;
lines.push(String(i + 1), `${at(start)} --> ${at(start + 200)}`, 'えっ', '');
}
const cues = parseSubtitleCues(lines.join('\n'), 'test.srt');
assert.equal(cues.length, 5);
});
test('parseSubtitleCues keeps a short SRT frame run below the minimum length', () => {
// Four 40ms frames: frame-speed, but too few to tell an animation from an artefact.
const lines: string[] = [];
for (let i = 0; i < 4; i++) {
const start = 7870 + i * 40;
const at = (ms: number) =>
`00:00:0${Math.floor(ms / 1000)},${String(ms % 1000).padStart(3, '0')}`;
lines.push(String(i + 1), `${at(start)} --> ${at(start + 40)}`, 'タイトル', '');
}
const cues = parseSubtitleCues(lines.join('\n'), 'test.srt');
assert.equal(cues.length, 4);
});
test('parseSubtitleCues applies ASS burst rules to ASS content behind an .srt filename', () => {
// The extension lies, so the SRT parser finds nothing and the content-sniffing fallback
// takes over -- which has to carry the `ass` source format with it, or the far stricter
// timing-only thresholds would let this karaoke burst through as three cues.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.20,Karaoke,,0,0,0,,{\\k20}歌詞',
'Dialogue: 0,0:00:01.20,0:00:01.40,Karaoke,,0,0,0,,{\\k20}歌詞',
'Dialogue: 0,0:00:01.40,0:00:03.00,Karaoke,,0,0,0,,{\\k20}歌詞',
].join('\n');
const cues = parseSubtitleCues(content, 'test.srt');
assert.equal(cues.length, 1);
assert.equal(cues[0]!.startTime, 1.0);
assert.equal(cues[0]!.endTime, 3.0);
assert.equal(cues[0]!.text, '歌詞');
});
test('parseSubtitleCues detects subtitle formats from remote URLs', () => { test('parseSubtitleCues detects subtitle formats from remote URLs', () => {
const assContent = [ const assContent = [
'[Events]', '[Events]',
+160 -34
View File
@@ -1,9 +1,46 @@
import {
assOverrideSignature,
assToPlainText,
collectAssOverrideCommands,
parseAssEffectField,
type AssEffectKind,
type AssOverrideCommand,
} from './ass-text';
import { mergeDuplicateCues } from './subtitle-cue-dedup';
export interface SubtitleCue { export interface SubtitleCue {
startTime: number; startTime: number;
endTime: number; endTime: number;
text: string; text: string;
} }
/**
* Everything the parser knows about a source event, shared only with the dedup engine.
* Deduplication needs the authoring context -- which style the line belongs to, which
* override commands it carries, whether the `Effect` column was set -- to tell a karaoke
* burst apart from two characters saying the same word in turn. None of it is meaningful
* outside the parser, so the public API stays `{startTime, endTime, text}`.
*/
export interface AnnotatedSubtitleCue extends SubtitleCue {
/** Text exactly as authored, override blocks and all. */
rawText: string;
style: string;
layer: number;
/** ASS `Name`/`Actor` column. */
name: string;
/** ASS `Effect` column, verbatim. */
effect: string;
effectKind: AssEffectKind;
/** Override commands found in `{...}` blocks, with their arguments. */
overrides: readonly AssOverrideCommand[];
/** Canonical form of `overrides`, for spotting values that change across a run. */
overrideSignature: string;
/** Position in the source file, so sorting by time stays deterministic across layers. */
order: number;
}
export type SubtitleSourceFormat = 'ass' | 'srt';
const HTML_SUBTITLE_TAG_PATTERN = /<\/?[A-Za-z][^>\n]*>/g; const HTML_SUBTITLE_TAG_PATTERN = /<\/?[A-Za-z][^>\n]*>/g;
const SRT_TIMING_PATTERN = const SRT_TIMING_PATTERN =
@@ -23,12 +60,21 @@ function parseTimestamp(
); );
} }
/**
* The single ASS decode for the file path: cues leave the parser as plain text with real
* line breaks, matching what mpv hands over for the same line played live. No layer
* downstream decodes ASS again.
*/
function sanitizeSubtitleCueText(text: string): string { function sanitizeSubtitleCueText(text: string): string {
return text.replace(ASS_OVERRIDE_TAG_PATTERN, '').replace(HTML_SUBTITLE_TAG_PATTERN, '').trim(); return assToPlainText(text, '\n').replace(HTML_SUBTITLE_TAG_PATTERN, '').trim();
} }
export function parseSrtCues(content: string): SubtitleCue[] { function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
const cues: SubtitleCue[] = []; return cues.map(({ startTime, endTime, text }) => ({ startTime, endTime, text }));
}
function parseAnnotatedSrtCues(content: string): AnnotatedSubtitleCue[] {
const cues: AnnotatedSubtitleCue[] = [];
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
let i = 0; let i = 0;
@@ -60,20 +106,39 @@ export function parseSrtCues(content: string): SubtitleCue[] {
i += 1; i += 1;
} }
const text = sanitizeSubtitleCueText(textLines.join('\n')); const rawText = textLines.join('\n');
const text = sanitizeSubtitleCueText(rawText);
if (text) { if (text) {
cues.push({ startTime, endTime, text }); cues.push({
startTime,
endTime,
text,
rawText,
style: '',
layer: 0,
name: '',
effect: '',
effectKind: 'none',
// SRT and VTT carry no authoring metadata, and the dedup engine never reads
// overrides for those formats -- collecting them would be parsing for nobody.
overrides: [],
overrideSignature: '',
order: cues.length,
});
} }
} }
return cues; return cues;
} }
const ASS_OVERRIDE_TAG_PATTERN = /\{[^}]*\}/g; export function parseSrtCues(content: string): SubtitleCue[] {
return toPublicCues(parseAnnotatedSrtCues(content));
}
const ASS_TIMING_PATTERN = /^(\d+):(\d{2}):(\d{2})\.(\d{1,2})$/; const ASS_TIMING_PATTERN = /^(\d+):(\d{2}):(\d{2})\.(\d{1,2})$/;
const ASS_FORMAT_PREFIX = 'Format:'; const ASS_FORMAT_PREFIX = 'Format:';
const ASS_DIALOGUE_PREFIX = 'Dialogue:'; const ASS_DIALOGUE_PREFIX = 'Dialogue:';
const ASS_NAME_FIELD_ALIASES = ['name', 'actor'];
function parseAssTimestamp(raw: string): number | null { function parseAssTimestamp(raw: string): number | null {
const match = ASS_TIMING_PATTERN.exec(raw.trim()); const match = ASS_TIMING_PATTERN.exec(raw.trim());
@@ -87,13 +152,43 @@ function parseAssTimestamp(raw: string): number | null {
return hours * 3600 + minutes * 60 + seconds + centiseconds / 100; return hours * 3600 + minutes * 60 + seconds + centiseconds / 100;
} }
export function parseAssCues(content: string): SubtitleCue[] { function readField(fields: string[], index: number): string {
const cues: SubtitleCue[] = []; return index >= 0 && index < fields.length ? fields[index]!.trim() : '';
}
function findFieldIndex(formatFields: string[], aliases: string[]): number {
for (const alias of aliases) {
const index = formatFields.indexOf(alias);
if (index >= 0) {
return index;
}
}
return -1;
}
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
const cues: AnnotatedSubtitleCue[] = [];
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
let inEventsSection = false; let inEventsSection = false;
let startFieldIndex = -1; const fieldIndex = {
let endFieldIndex = -1; start: -1,
let textFieldIndex = -1; end: -1,
text: -1,
style: -1,
layer: -1,
name: -1,
effect: -1,
};
const resetFieldIndex = () => {
fieldIndex.start = -1;
fieldIndex.end = -1;
fieldIndex.text = -1;
fieldIndex.style = -1;
fieldIndex.layer = -1;
fieldIndex.name = -1;
fieldIndex.effect = -1;
};
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
@@ -101,9 +196,7 @@ export function parseAssCues(content: string): SubtitleCue[] {
if (trimmed.startsWith('[') && trimmed.endsWith(']')) { if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
inEventsSection = trimmed.toLowerCase() === '[events]'; inEventsSection = trimmed.toLowerCase() === '[events]';
if (!inEventsSection) { if (!inEventsSection) {
startFieldIndex = -1; resetFieldIndex();
endFieldIndex = -1;
textFieldIndex = -1;
} }
continue; continue;
} }
@@ -117,9 +210,15 @@ export function parseAssCues(content: string): SubtitleCue[] {
.slice(ASS_FORMAT_PREFIX.length) .slice(ASS_FORMAT_PREFIX.length)
.split(',') .split(',')
.map((field) => field.trim().toLowerCase()); .map((field) => field.trim().toLowerCase());
startFieldIndex = formatFields.indexOf('start'); fieldIndex.start = formatFields.indexOf('start');
endFieldIndex = formatFields.indexOf('end'); fieldIndex.end = formatFields.indexOf('end');
textFieldIndex = formatFields.indexOf('text'); fieldIndex.text = formatFields.indexOf('text');
fieldIndex.style = formatFields.indexOf('style');
fieldIndex.layer = formatFields.indexOf('layer');
// Aegisub writes the speaker column as `Actor`; the v4+ spec calls it `Name`.
// Missing it costs the burst check its speaker guard, so both spellings count.
fieldIndex.name = findFieldIndex(formatFields, ASS_NAME_FIELD_ALIASES);
fieldIndex.effect = formatFields.indexOf('effect');
continue; continue;
} }
@@ -127,34 +226,57 @@ export function parseAssCues(content: string): SubtitleCue[] {
continue; continue;
} }
if (startFieldIndex < 0 || endFieldIndex < 0 || textFieldIndex < 0) { if (fieldIndex.start < 0 || fieldIndex.end < 0 || fieldIndex.text < 0) {
continue; continue;
} }
const fields = trimmed.slice(ASS_DIALOGUE_PREFIX.length).split(','); const fields = trimmed.slice(ASS_DIALOGUE_PREFIX.length).split(',');
if ( if (
startFieldIndex >= fields.length || fieldIndex.start >= fields.length ||
endFieldIndex >= fields.length || fieldIndex.end >= fields.length ||
textFieldIndex >= fields.length fieldIndex.text >= fields.length
) { ) {
continue; continue;
} }
const startTime = parseAssTimestamp(fields[startFieldIndex]!); const startTime = parseAssTimestamp(fields[fieldIndex.start]!);
const endTime = parseAssTimestamp(fields[endFieldIndex]!); const endTime = parseAssTimestamp(fields[fieldIndex.end]!);
if (startTime === null || endTime === null) { if (startTime === null || endTime === null) {
continue; continue;
} }
const text = sanitizeSubtitleCueText(fields.slice(textFieldIndex).join(',')); const rawText = fields.slice(fieldIndex.text).join(',');
if (text) { const text = sanitizeSubtitleCueText(rawText);
cues.push({ startTime, endTime, text }); if (!text) {
continue;
} }
const effect = readField(fields, fieldIndex.effect);
const layer = Number(readField(fields, fieldIndex.layer));
const overrides = collectAssOverrideCommands(rawText);
cues.push({
startTime,
endTime,
text,
rawText,
style: readField(fields, fieldIndex.style),
layer: Number.isFinite(layer) ? layer : 0,
name: readField(fields, fieldIndex.name),
effect,
effectKind: parseAssEffectField(effect),
overrides,
overrideSignature: assOverrideSignature(overrides),
order: cues.length,
});
} }
return cues; return cues;
} }
export function parseAssCues(content: string): SubtitleCue[] {
return toPublicCues(parseAnnotatedAssCues(content));
}
function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null { function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null {
const [normalizedSource = source] = const [normalizedSource = source] =
(() => { (() => {
@@ -173,27 +295,31 @@ function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | n
export function parseSubtitleCues(content: string, filename: string): SubtitleCue[] { export function parseSubtitleCues(content: string, filename: string): SubtitleCue[] {
const format = detectSubtitleFormat(filename); const format = detectSubtitleFormat(filename);
let cues: SubtitleCue[]; let cues: AnnotatedSubtitleCue[];
let sourceFormat: SubtitleSourceFormat = 'srt';
switch (format) { switch (format) {
case 'srt': case 'srt':
case 'vtt': case 'vtt':
cues = parseSrtCues(content); cues = parseAnnotatedSrtCues(content);
break; break;
case 'ass': case 'ass':
case 'ssa': case 'ssa':
cues = parseAssCues(content); cues = parseAnnotatedAssCues(content);
sourceFormat = 'ass';
break; break;
default: default:
cues = []; cues = [];
} }
if (cues.length === 0) { if (cues.length === 0) {
const assCues = parseAssCues(content); const assCues = parseAnnotatedAssCues(content);
const srtCues = parseSrtCues(content); const srtCues = parseAnnotatedSrtCues(content);
cues = assCues.length >= srtCues.length ? assCues : srtCues; const preferAss = assCues.length >= srtCues.length;
cues = preferAss ? assCues : srtCues;
sourceFormat = preferAss && assCues.length > 0 ? 'ass' : 'srt';
} }
cues.sort((a, b) => a.startTime - b.startTime); cues.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime || a.order - b.order);
return cues; return toPublicCues(mergeDuplicateCues(cues, sourceFormat));
} }
+17 -18
View File
@@ -74,7 +74,6 @@ test('prefetch service tokenizes priority window cues and caches them', async ()
preCacheTokenization: (text, data) => { preCacheTokenization: (text, data) => {
cached.set(text, data); cached.set(text, data);
}, },
isCacheFull: () => false,
priorityWindowSize: 3, priorityWindowSize: 3,
}); });
@@ -91,32 +90,38 @@ test('prefetch service tokenizes priority window cues and caches them', async ()
assert.ok(cached.has('line-2')); assert.ok(cached.has('line-2'));
}); });
test('prefetch service stops when cache is full', async () => { test('prefetch service warms every cue even when the cache evicts along the way', async () => {
const cues = makeCues(20); const cues = makeCues(20);
let tokenizeCalls = 0; const tokenized: string[] = [];
let cacheSize = 0; // Stand-in for the LRU: only the last 5 entries survive, so later cues evict earlier ones.
const cache = new Set<string>();
const service = createSubtitlePrefetchService({ const service = createSubtitlePrefetchService({
cues, cues,
tokenizeSubtitle: async (text) => { tokenizeSubtitle: async (text) => {
tokenizeCalls += 1; tokenized.push(text);
return { text, tokens: [] }; return { text, tokens: [] };
}, },
preCacheTokenization: () => { preCacheTokenization: (text) => {
cacheSize += 1; cache.add(text);
while (cache.size > 5) {
const oldest = cache.values().next().value;
if (oldest === undefined) break;
cache.delete(oldest);
}
}, },
isCacheFull: () => cacheSize >= 5, hasCachedTokenization: (text) => cache.has(text),
priorityWindowSize: 3, priorityWindowSize: 3,
}); });
service.start(0); service.start(0);
for (let i = 0; i < 30; i += 1) { for (let i = 0; i < 60; i += 1) {
await flushMicrotasks(); await flushMicrotasks();
} }
service.stop(); service.stop();
// Should have stopped at 5 (cache full), not tokenized all 20 assert.equal(tokenized.length, 20, `Expected all 20 cues warmed, got ${tokenized.length}`);
assert.ok(tokenizeCalls <= 6, `Expected <= 6 tokenize calls, got ${tokenizeCalls}`); assert.equal(new Set(tokenized).size, 20, 'Each cue is tokenized at most once per run');
}); });
test('prefetch service can be stopped mid-flight', async () => { test('prefetch service can be stopped mid-flight', async () => {
@@ -130,7 +135,6 @@ test('prefetch service can be stopped mid-flight', async () => {
return { text, tokens: [] }; return { text, tokens: [] };
}, },
preCacheTokenization: () => {}, preCacheTokenization: () => {},
isCacheFull: () => false,
priorityWindowSize: 3, priorityWindowSize: 3,
}); });
@@ -159,7 +163,6 @@ test('prefetch service onSeek re-prioritizes from new position', async () => {
preCacheTokenization: (text) => { preCacheTokenization: (text) => {
cachedTexts.push(text); cachedTexts.push(text);
}, },
isCacheFull: () => false,
priorityWindowSize: 3, priorityWindowSize: 3,
}); });
@@ -183,7 +186,7 @@ test('prefetch service onSeek re-prioritizes from new position', async () => {
assert.ok(hasPostSeekCue, 'Should have cached cues after seek position'); assert.ok(hasPostSeekCue, 'Should have cached cues after seek position');
}); });
test('prefetch service still warms the priority window when cache is full', async () => { test('prefetch service warms the priority window ahead of the rest of the file', async () => {
const cues = makeCues(20); const cues = makeCues(20);
const cachedTexts: string[] = []; const cachedTexts: string[] = [];
@@ -193,7 +196,6 @@ test('prefetch service still warms the priority window when cache is full', asyn
preCacheTokenization: (text) => { preCacheTokenization: (text) => {
cachedTexts.push(text); cachedTexts.push(text);
}, },
isCacheFull: () => true,
priorityWindowSize: 3, priorityWindowSize: 3,
}); });
@@ -217,7 +219,6 @@ test('prefetch service pause/resume halts and continues tokenization', async ()
return { text, tokens: [] }; return { text, tokens: [] };
}, },
preCacheTokenization: () => {}, preCacheTokenization: () => {},
isCacheFull: () => false,
priorityWindowSize: 3, priorityWindowSize: 3,
}); });
@@ -255,7 +256,6 @@ test('prefetch service skips cues already present in tokenization cache', async
}, },
preCacheTokenization: () => {}, preCacheTokenization: () => {},
hasCachedTokenization: (text) => text === 'line-0' || text === 'line-1', hasCachedTokenization: (text) => text === 'line-0' || text === 'line-1',
isCacheFull: () => false,
priorityWindowSize: 3, priorityWindowSize: 3,
}); });
@@ -285,7 +285,6 @@ test('prefetch service deduplicates repeated cue text within a run', async () =>
return { text, tokens: [] }; return { text, tokens: [] };
}, },
preCacheTokenization: () => {}, preCacheTokenization: () => {},
isCacheFull: () => false,
priorityWindowSize: 3, priorityWindowSize: 3,
}); });
+5 -7
View File
@@ -7,7 +7,6 @@ export interface SubtitlePrefetchServiceDeps {
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>; tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
preCacheTokenization: (text: string, data: SubtitleData) => void; preCacheTokenization: (text: string, data: SubtitleData) => void;
hasCachedTokenization?: (text: string) => boolean; hasCachedTokenization?: (text: string) => boolean;
isCacheFull: () => boolean;
priorityWindowSize?: number; priorityWindowSize?: number;
} }
@@ -57,11 +56,14 @@ export function createSubtitlePrefetchService(
let paused = false; let paused = false;
let currentRunId = 0; let currentRunId = 0;
// A run is a single bounded pass over one file's cues, deduped by `warmedKeys` and by
// `hasCachedTokenization`, so the worst case is one tokenization per cue. The cache is
// an LRU and bounds its own memory, so a full cache is not a reason to stop warming;
// stopping there used to leave the tail of longer media permanently uncached.
async function tokenizeCueList( async function tokenizeCueList(
cuesToProcess: SubtitleCue[], cuesToProcess: SubtitleCue[],
runId: number, runId: number,
warmedKeys: Set<string>, warmedKeys: Set<string>,
options: { allowWhenCacheFull?: boolean } = {},
): Promise<void> { ): Promise<void> {
for (const cue of cuesToProcess) { for (const cue of cuesToProcess) {
if (stopped || runId !== currentRunId) { if (stopped || runId !== currentRunId) {
@@ -77,10 +79,6 @@ export function createSubtitlePrefetchService(
return; return;
} }
if (!options.allowWhenCacheFull && deps.isCacheFull()) {
return;
}
const cacheKey = normalizeSubtitleCacheKey(cue.text); const cacheKey = normalizeSubtitleCacheKey(cue.text);
if (!cacheKey || warmedKeys.has(cacheKey) || deps.hasCachedTokenization?.(cue.text)) { if (!cacheKey || warmedKeys.has(cacheKey) || deps.hasCachedTokenization?.(cue.text)) {
if (cacheKey) { if (cacheKey) {
@@ -110,7 +108,7 @@ export function createSubtitlePrefetchService(
// Phase 1: Priority window // Phase 1: Priority window
const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize); const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize);
await tokenizeCueList(priorityCues, runId, warmedKeys, { allowWhenCacheFull: true }); await tokenizeCueList(priorityCues, runId, warmedKeys);
if (stopped || runId !== currentRunId) { if (stopped || runId !== currentRunId) {
return; return;
@@ -0,0 +1,159 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { SubtitleData } from '../../types';
import { createSubtitleProcessingController } from './subtitle-processing-controller';
function flushMicrotasks(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
test('new subtitle emits plain immediately without parallel tokenization or a stale overwrite', async () => {
const emitted: SubtitleData[] = [];
const resolvers = new Map<string, (value: SubtitleData | null) => void>();
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) =>
await new Promise<SubtitleData | null>((resolve) => {
resolvers.set(text, resolve);
}),
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('first');
controller.onSubtitleChange('second');
assert.deepEqual(emitted, [
{ text: 'first', tokens: null },
{ text: 'second', tokens: null },
]);
assert.equal(resolvers.has('second'), false);
const resolveFirst = resolvers.get('first');
assert.ok(resolveFirst);
resolveFirst({ text: 'first', tokens: [] });
await flushMicrotasks();
assert.deepEqual(emitted, [
{ text: 'first', tokens: null },
{ text: 'second', tokens: null },
]);
assert.equal(resolvers.has('second'), true);
const resolveSecond = resolvers.get('second');
assert.ok(resolveSecond);
resolveSecond({ text: 'second', tokens: [] });
await flushMicrotasks();
assert.deepEqual(emitted, [
{ text: 'first', tokens: null },
{ text: 'second', tokens: null },
{ text: 'second', tokens: [] },
]);
});
test('subtitle clears immediately while previous tokenization remains pending', async () => {
const emitted: SubtitleData[] = [];
let resolveTokenization: ((value: SubtitleData | null) => void) | undefined;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async () =>
await new Promise<SubtitleData | null>((resolve) => {
resolveTokenization = resolve;
}),
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('first');
controller.onSubtitleChange('');
assert.deepEqual(emitted, [
{ text: 'first', tokens: null },
{ text: '', tokens: null },
]);
assert.ok(resolveTokenization);
resolveTokenization({ text: 'first', tokens: [] });
await flushMicrotasks();
assert.deepEqual(emitted, [
{ text: 'first', tokens: null },
{ text: '', tokens: null },
]);
});
test('returning to an uncached completed line emits it while another line is pending', async () => {
const emitted: SubtitleData[] = [];
let resolvePending: ((value: SubtitleData | null) => void) | undefined;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
if (text === 'A') {
return { text, tokens: [] };
}
return await new Promise<SubtitleData | null>((resolve) => {
resolvePending = resolve;
});
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('A');
await flushMicrotasks();
controller.invalidateTokenizationCache();
controller.onSubtitleChange('B');
controller.onSubtitleChange('A');
assert.deepEqual(emitted.at(-1), { text: 'A', tokens: null });
assert.ok(resolvePending);
resolvePending({ text: 'B', tokens: [] });
});
test('ABA subtitle changes reuse the matching first tokenization only after A is current again', async () => {
const emitted: SubtitleData[] = [];
const tokenizeCalls: string[] = [];
const resolvers: Array<(value: SubtitleData | null) => void> = [];
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
tokenizeCalls.push(text);
return await new Promise<SubtitleData | null>((resolve) => {
resolvers.push(resolve);
});
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('A');
controller.onSubtitleChange('B');
controller.onSubtitleChange('A');
const resolveFirst = resolvers[0];
assert.ok(resolveFirst);
resolveFirst({ text: 'A', tokens: [{ value: 1 } as never] });
await flushMicrotasks();
assert.deepEqual(tokenizeCalls, ['A']);
assert.deepEqual(emitted, [
{ text: 'A', tokens: null },
{ text: 'B', tokens: null },
{ text: 'A', tokens: null },
{ text: 'A', tokens: [{ value: 1 } as never] },
]);
});
test('cached next subtitle does not downgrade to plain while processing is busy', async () => {
const emitted: SubtitleData[] = [];
let resolveTokenization: ((value: SubtitleData | null) => void) | undefined;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) =>
await new Promise<SubtitleData | null>((resolve) => {
resolveTokenization = () => resolve({ text, tokens: [] });
}),
emitSubtitle: (payload) => emitted.push(payload),
});
controller.preCacheTokenization('cached', { text: 'cached', tokens: [] });
controller.onSubtitleChange('pending');
controller.onSubtitleChange('cached');
assert.deepEqual(emitted, [{ text: 'pending', tokens: null }]);
assert.ok(resolveTokenization);
resolveTokenization({ text: 'pending', tokens: [] });
await flushMicrotasks();
assert.deepEqual(emitted, [
{ text: 'pending', tokens: null },
{ text: 'cached', tokens: [] },
]);
});
@@ -7,18 +7,153 @@ function flushMicrotasks(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0)); return new Promise((resolve) => setTimeout(resolve, 0));
} }
test('subtitle processing emits tokenized payload when tokenization succeeds', async () => { test('subtitle processing emits plain payload immediately on cache miss, then tokenized payload', async () => {
const emitted: SubtitleData[] = []; const emitted: SubtitleData[] = [];
const controller = createSubtitleProcessingController({ const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: [] }), tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
emitSubtitle: (payload) => emitted.push(payload), emitSubtitle: (payload) => emitted.push(payload),
}); });
controller.onSubtitleChange('字幕');
assert.deepEqual(emitted, [{ text: '字幕', tokens: null }]);
await flushMicrotasks();
assert.deepEqual(emitted, [
{ text: '字幕', tokens: null },
{ text: '字幕', tokens: [] },
]);
});
test('cache invalidation during pending tokenization does not re-emit the plain payload', async () => {
const emitted: SubtitleData[] = [];
const resolvers: Array<(value: SubtitleData | null) => void> = [];
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) =>
await new Promise<SubtitleData | null>((resolve) => {
resolvers.push(() => resolve({ text, tokens: [{ value: resolvers.length } as never] }));
}),
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('行');
assert.deepEqual(emitted, [{ text: '行', tokens: null }]);
controller.invalidateTokenizationCache();
resolvers[0]?.({ text: '行', tokens: [] });
await flushMicrotasks();
// Retry for the new generation is now pending; still no duplicate plain emit.
assert.deepEqual(emitted, [{ text: '行', tokens: null }]);
resolvers[1]?.({ text: '行', tokens: [] });
await flushMicrotasks();
assert.deepEqual(emitted, [
{ text: '行', tokens: null },
{ text: '行', tokens: [{ value: 2 } as never] },
]);
});
test('failed refresh does not downgrade an already emitted tokenized subtitle', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
tokenizeCalls += 1;
if (tokenizeCalls > 1) {
throw new Error('tokenizer gone');
}
return { text, tokens: [] };
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('行');
await flushMicrotasks();
controller.invalidateTokenizationCache();
controller.refreshCurrentSubtitle();
await flushMicrotasks();
assert.equal(tokenizeCalls, 2);
assert.deepEqual(emitted, [
{ text: '行', tokens: null },
{ text: '行', tokens: [] },
]);
});
test('null-tokenization refresh does not downgrade an already emitted tokenized subtitle', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
tokenizeCalls += 1;
return tokenizeCalls > 1 ? null : { text, tokens: [] };
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('行');
await flushMicrotasks();
controller.invalidateTokenizationCache();
controller.refreshCurrentSubtitle();
await flushMicrotasks();
assert.equal(tokenizeCalls, 2);
assert.deepEqual(emitted, [
{ text: '行', tokens: null },
{ text: '行', tokens: [] },
]);
});
test('subtitle processing does not emit plain payload for cached lines', async () => {
const emitted: SubtitleData[] = [];
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
emitSubtitle: (payload) => emitted.push(payload),
});
controller.preCacheTokenization('字幕', { text: '字幕', tokens: [] });
controller.onSubtitleChange('字幕'); controller.onSubtitleChange('字幕');
await flushMicrotasks(); await flushMicrotasks();
assert.deepEqual(emitted, [{ text: '字幕', tokens: [] }]); assert.deepEqual(emitted, [{ text: '字幕', tokens: [] }]);
}); });
test('text that normalizes to nothing is never cached', () => {
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
emitSubtitle: () => {},
});
// Two different inputs both reduce to an empty key; sharing one entry would serve the
// first one's tokens for the second.
controller.preCacheTokenization(' ', { text: ' ', tokens: [] });
assert.equal(controller.hasCachedSubtitle(' '), false);
assert.equal(controller.hasCachedSubtitle('\\n'), false);
assert.equal(controller.consumeCachedSubtitle('\\n'), null);
});
test('subtitle processing shows plain line while tokenization is still pending', async () => {
const emitted: SubtitleData[] = [];
let resolveTokenization: ((value: SubtitleData | null) => void) | undefined;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) =>
await new Promise<SubtitleData | null>((resolve) => {
resolveTokenization = () => resolve({ text, tokens: [] });
}),
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('遅い行');
await flushMicrotasks();
assert.deepEqual(emitted, [{ text: '遅い行', tokens: null }]);
assert.ok(resolveTokenization);
resolveTokenization({ text: '遅い行', tokens: [] });
await flushMicrotasks();
assert.deepEqual(emitted, [
{ text: '遅い行', tokens: null },
{ text: '遅い行', tokens: [] },
]);
});
test('subtitle processing drops stale tokenization and delivers latest subtitle only once', async () => { test('subtitle processing drops stale tokenization and delivers latest subtitle only once', async () => {
const emitted: SubtitleData[] = []; const emitted: SubtitleData[] = [];
let firstResolve: ((value: SubtitleData | null) => void) | undefined; let firstResolve: ((value: SubtitleData | null) => void) | undefined;
@@ -41,7 +176,11 @@ test('subtitle processing drops stale tokenization and delivers latest subtitle
await flushMicrotasks(); await flushMicrotasks();
await flushMicrotasks(); await flushMicrotasks();
assert.deepEqual(emitted, [{ text: 'second', tokens: [] }]); assert.deepEqual(emitted, [
{ text: 'first', tokens: null },
{ text: 'second', tokens: null },
{ text: 'second', tokens: [] },
]);
}); });
test('subtitle processing skips duplicate subtitle emission', async () => { test('subtitle processing skips duplicate subtitle emission', async () => {
@@ -60,7 +199,10 @@ test('subtitle processing skips duplicate subtitle emission', async () => {
controller.onSubtitleChange('same'); controller.onSubtitleChange('same');
await flushMicrotasks(); await flushMicrotasks();
assert.equal(emitted.length, 1); assert.deepEqual(emitted, [
{ text: 'same', tokens: null },
{ text: 'same', tokens: [] },
]);
assert.equal(tokenizeCalls, 1); assert.equal(tokenizeCalls, 1);
}); });
@@ -84,7 +226,9 @@ test('subtitle processing reuses cached tokenization for repeated subtitle text'
assert.equal(tokenizeCalls, 2); assert.equal(tokenizeCalls, 2);
assert.deepEqual(emitted, [ assert.deepEqual(emitted, [
{ text: 'first', tokens: null },
{ text: 'first', tokens: [] }, { text: 'first', tokens: [] },
{ text: 'second', tokens: null },
{ text: 'second', tokens: [] }, { text: 'second', tokens: [] },
{ text: 'first', tokens: [] }, { text: 'first', tokens: [] },
]); ]);
@@ -100,7 +244,48 @@ test('subtitle processing falls back to plain subtitle when tokenization returns
controller.onSubtitleChange('fallback'); controller.onSubtitleChange('fallback');
await flushMicrotasks(); await flushMicrotasks();
assert.deepEqual(
emitted,
[{ text: 'fallback', tokens: null }],
'plain payload should not be re-emitted when tokenization yields nothing new',
);
});
test('null tokenization is not cached and a later cue retries tokenization', async () => {
const emitted: SubtitleData[] = [];
const callsByText = new Map<string, number>();
let failNext = true;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
callsByText.set(text, (callsByText.get(text) ?? 0) + 1);
if (text === 'fallback' && failNext) {
failNext = false;
return null;
}
return { text, tokens: [] };
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('fallback');
await flushMicrotasks();
assert.equal(callsByText.get('fallback'), 1);
assert.equal(
controller.hasCachedSubtitle('fallback'),
false,
'plain fallback must not be cached when tokenization yields nothing',
);
assert.deepEqual(emitted, [{ text: 'fallback', tokens: null }]); assert.deepEqual(emitted, [{ text: 'fallback', tokens: null }]);
controller.onSubtitleChange('other');
await flushMicrotasks();
controller.onSubtitleChange('fallback');
await flushMicrotasks();
assert.equal(callsByText.get('fallback'), 2, 'later cue should retry tokenization');
assert.equal(controller.hasCachedSubtitle('fallback'), true);
assert.deepEqual(emitted.at(-1), { text: 'fallback', tokens: [] });
}); });
test('subtitle processing ignores duplicate current subtitle refresh without cache invalidation', async () => { test('subtitle processing ignores duplicate current subtitle refresh without cache invalidation', async () => {
@@ -120,7 +305,10 @@ test('subtitle processing ignores duplicate current subtitle refresh without cac
await flushMicrotasks(); await flushMicrotasks();
assert.equal(tokenizeCalls, 1); assert.equal(tokenizeCalls, 1);
assert.deepEqual(emitted, [{ text: 'same', tokens: [] }]); assert.deepEqual(emitted, [
{ text: 'same', tokens: null },
{ text: 'same', tokens: [] },
]);
}); });
test('subtitle processing coalesces refresh requests while current subtitle is processing', async () => { test('subtitle processing coalesces refresh requests while current subtitle is processing', async () => {
@@ -146,7 +334,10 @@ test('subtitle processing coalesces refresh requests while current subtitle is p
await flushMicrotasks(); await flushMicrotasks();
assert.equal(tokenizeCalls, 1); assert.equal(tokenizeCalls, 1);
assert.deepEqual(emitted, [{ text: 'same', tokens: [] }]); assert.deepEqual(emitted, [
{ text: 'same', tokens: null },
{ text: 'same', tokens: [] },
]);
}); });
test('subtitle processing refresh re-tokenizes after cache invalidation', async () => { test('subtitle processing refresh re-tokenizes after cache invalidation', async () => {
@@ -168,6 +359,7 @@ test('subtitle processing refresh re-tokenizes after cache invalidation', async
assert.equal(tokenizeCalls, 2); assert.equal(tokenizeCalls, 2);
assert.deepEqual(emitted, [ assert.deepEqual(emitted, [
{ text: 'same', tokens: null },
{ text: 'same', tokens: [{ value: 1 } as never] }, { text: 'same', tokens: [{ value: 1 } as never] },
{ text: 'same', tokens: [{ value: 2 } as never] }, { text: 'same', tokens: [{ value: 2 } as never] },
]); ]);
@@ -183,7 +375,10 @@ test('subtitle processing refresh can use explicit text override', async () => {
controller.refreshCurrentSubtitle('initial'); controller.refreshCurrentSubtitle('initial');
await flushMicrotasks(); await flushMicrotasks();
assert.deepEqual(emitted, [{ text: 'initial', tokens: [] }]); assert.deepEqual(emitted, [
{ text: 'initial', tokens: null },
{ text: 'initial', tokens: [] },
]);
}); });
test('subtitle processing cache invalidation only affects future subtitle events', async () => { test('subtitle processing cache invalidation only affects future subtitle events', async () => {
@@ -205,10 +400,10 @@ test('subtitle processing cache invalidation only affects future subtitle events
await flushMicrotasks(); await flushMicrotasks();
assert.equal(callsByText.get('same'), 1); assert.equal(callsByText.get('same'), 1);
assert.equal(emitted.length, 3); assert.equal(emitted.length, 5);
controller.invalidateTokenizationCache(); controller.invalidateTokenizationCache();
assert.equal(emitted.length, 3); assert.equal(emitted.length, 5);
controller.onSubtitleChange('different'); controller.onSubtitleChange('different');
await flushMicrotasks(); await flushMicrotasks();
@@ -308,25 +503,54 @@ test('hasCachedSubtitle checks prefetched entries without consuming them', async
assert.equal(controller.hasCachedSubtitle('猫\nです'), false); assert.equal(controller.hasCachedSubtitle('猫\nです'), false);
}); });
test('isCacheFull returns false when cache is below limit', () => { test('cache keeps every entry while below the limit', () => {
const controller = createSubtitleProcessingController({ const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: null }), tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
emitSubtitle: () => {}, emitSubtitle: () => {},
cacheLimit: 8,
}); });
assert.equal(controller.isCacheFull(), false); for (let i = 0; i < 8; i += 1) {
controller.preCacheTokenization(`line-${i}`, { text: `line-${i}`, tokens: [] });
}
assert.deepEqual(
Array.from({ length: 8 }, (_, i) => controller.hasCachedSubtitle(`line-${i}`)),
Array.from({ length: 8 }, () => true),
);
}); });
test('isCacheFull returns true when cache reaches limit', async () => { test('cache evicts least recently used entries once the limit is reached', () => {
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
emitSubtitle: () => {},
cacheLimit: 3,
});
for (const line of ['a', 'b', 'c']) {
controller.preCacheTokenization(line, { text: line, tokens: [] });
}
// Touching 'a' makes 'b' the eviction candidate.
controller.consumeCachedSubtitle('a');
controller.preCacheTokenization('d', { text: 'd', tokens: [] });
assert.equal(controller.hasCachedSubtitle('b'), false);
assert.deepEqual(
['a', 'c', 'd'].map((line) => controller.hasCachedSubtitle(line)),
[true, true, true],
);
});
test('default cache limit covers a full-length title without evicting', () => {
const controller = createSubtitleProcessingController({ const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: [] }), tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
emitSubtitle: () => {}, emitSubtitle: () => {},
}); });
// Fill cache to the 256 limit for (let i = 0; i < 2000; i += 1) {
for (let i = 0; i < 256; i += 1) {
controller.preCacheTokenization(`line-${i}`, { text: `line-${i}`, tokens: [] }); controller.preCacheTokenization(`line-${i}`, { text: `line-${i}`, tokens: [] });
} }
assert.equal(controller.isCacheFull(), true); assert.equal(controller.hasCachedSubtitle('line-0'), true);
assert.equal(controller.hasCachedSubtitle('line-1999'), true);
}); });
@@ -1,12 +1,22 @@
import type { SubtitleData } from '../../types'; import type { SubtitleData } from '../../types';
import { normalizePlainSubtitleText } from './ass-text';
export interface SubtitleProcessingControllerDeps { export interface SubtitleProcessingControllerDeps {
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>; tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
emitSubtitle: (payload: SubtitleData) => void; emitSubtitle: (payload: SubtitleData) => void;
logDebug?: (message: string) => void; logDebug?: (message: string) => void;
now?: () => number; now?: () => number;
cacheLimit?: number;
} }
/**
* Pure memory bound on the LRU, not a coverage limit: prefetching runs to the end of a
* file regardless of cache pressure. Sized to hold a feature-length title (a 24-minute
* episode runs 300-400 lines, a 2-hour film ~2000) plus room for lines that repeat across
* episodes of a series, so openings and endings stay warm between titles.
*/
export const DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT = 2500;
export interface SubtitleProcessingController { export interface SubtitleProcessingController {
onSubtitleChange: (text: string) => void; onSubtitleChange: (text: string) => void;
refreshCurrentSubtitle: (textOverride?: string) => void; refreshCurrentSubtitle: (textOverride?: string) => void;
@@ -14,19 +24,31 @@ export interface SubtitleProcessingController {
preCacheTokenization: (text: string, data: SubtitleData) => void; preCacheTokenization: (text: string, data: SubtitleData) => void;
consumeCachedSubtitle: (text: string) => SubtitleData | null; consumeCachedSubtitle: (text: string) => SubtitleData | null;
hasCachedSubtitle: (text: string) => boolean; hasCachedSubtitle: (text: string) => boolean;
isCacheFull: () => boolean;
} }
/**
* Prefetched cues and live mpv text are both already decoded from ASS, so the key only
* has to settle whitespace for one authored line to resolve to one entry.
*
* An empty key is not a line: it is whatever normalization reduced to nothing. Callers
* must skip the cache for it rather than let every such input share one entry.
*/
export function normalizeSubtitleCacheKey(text: string): string { export function normalizeSubtitleCacheKey(text: string): string {
return text.replace(/\r\n/g, '\n').replace(/\\N/g, '\n').replace(/\\n/g, '\n').trim(); return normalizePlainSubtitleText(text);
} }
export function createSubtitleProcessingController( export function createSubtitleProcessingController(
deps: SubtitleProcessingControllerDeps, deps: SubtitleProcessingControllerDeps,
): SubtitleProcessingController { ): SubtitleProcessingController {
const SUBTITLE_TOKENIZATION_CACHE_LIMIT = 256; const SUBTITLE_TOKENIZATION_CACHE_LIMIT =
deps.cacheLimit && deps.cacheLimit > 0
? deps.cacheLimit
: DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT;
let latestText = ''; let latestText = '';
let lastEmittedText = ''; let lastEmittedText = '';
// Tracks the latest provisional plain emit across rapid changes and loop retries
// so the same line is never shown plain twice.
let lastPlainEmittedText: string | null = null;
let cacheGeneration = 0; let cacheGeneration = 0;
let lastEmittedGeneration = 0; let lastEmittedGeneration = 0;
let processing = false; let processing = false;
@@ -36,6 +58,9 @@ export function createSubtitleProcessingController(
const getCachedTokenization = (text: string): SubtitleData | null => { const getCachedTokenization = (text: string): SubtitleData | null => {
const cacheKey = normalizeSubtitleCacheKey(text); const cacheKey = normalizeSubtitleCacheKey(text);
if (!cacheKey) {
return null;
}
const cached = tokenizationCache.get(cacheKey); const cached = tokenizationCache.get(cacheKey);
if (!cached) { if (!cached) {
return null; return null;
@@ -47,7 +72,11 @@ export function createSubtitleProcessingController(
}; };
const setCachedTokenization = (text: string, payload: SubtitleData): void => { const setCachedTokenization = (text: string, payload: SubtitleData): void => {
tokenizationCache.set(normalizeSubtitleCacheKey(text), payload); const cacheKey = normalizeSubtitleCacheKey(text);
if (!cacheKey) {
return;
}
tokenizationCache.set(cacheKey, payload);
while (tokenizationCache.size > SUBTITLE_TOKENIZATION_CACHE_LIMIT) { while (tokenizationCache.size > SUBTITLE_TOKENIZATION_CACHE_LIMIT) {
const firstKey = tokenizationCache.keys().next().value; const firstKey = tokenizationCache.keys().next().value;
if (firstKey !== undefined) { if (firstKey !== undefined) {
@@ -70,9 +99,12 @@ export function createSubtitleProcessingController(
const startedAtMs = now(); const startedAtMs = now();
if (!text.trim()) { if (!text.trim()) {
if (lastPlainEmittedText !== text) {
deps.emitSubtitle({ text, tokens: null }); deps.emitSubtitle({ text, tokens: null });
}
lastEmittedText = text; lastEmittedText = text;
lastEmittedGeneration = generation; lastEmittedGeneration = generation;
lastPlainEmittedText = null;
break; break;
} }
@@ -82,11 +114,25 @@ export function createSubtitleProcessingController(
if (cachedTokenized) { if (cachedTokenized) {
output = cachedTokenized; output = cachedTokenized;
} else { } else {
// Cache miss: show the plain line on time; the tokenized payload
// upgrades it once ready. Skipped on refreshes of an already
// emitted line so downstream consumers never see a downgrade.
if (text !== lastEmittedText && text !== lastPlainEmittedText) {
deps.emitSubtitle({ text, tokens: null });
lastPlainEmittedText = text;
}
const tokenized = await deps.tokenizeSubtitle(text); const tokenized = await deps.tokenizeSubtitle(text);
// A null result is a transient tokenizer failure, not a verdict on
// the line: caching the plain fallback would pin it untokenized for
// every later occurrence.
if (tokenized) { if (tokenized) {
output = tokenized; output = tokenized;
// A result computed before an invalidation must not repopulate the
// fresh cache, or the retry below would serve the stale entry.
if (generation === cacheGeneration) {
setCachedTokenization(text, tokenized);
}
} }
setCachedTokenization(text, output);
} }
} catch (error) { } catch (error) {
deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`); deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`);
@@ -107,9 +153,16 @@ export function createSubtitleProcessingController(
continue; continue;
} }
// An untokenized result adds nothing when this line was already shown,
// either provisionally or as an earlier full emit (failed refresh) —
// emitting it would duplicate or downgrade what is on screen.
const plainAlreadyShown = lastPlainEmittedText === text || lastEmittedText === text;
if (!(output.tokens === null && output.text === text && plainAlreadyShown)) {
deps.emitSubtitle(output); deps.emitSubtitle(output);
}
lastEmittedText = text; lastEmittedText = text;
lastEmittedGeneration = generation; lastEmittedGeneration = generation;
lastPlainEmittedText = null;
deps.logDebug?.( deps.logDebug?.(
`Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`, `Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
); );
@@ -136,6 +189,14 @@ export function createSubtitleProcessingController(
return; return;
} }
latestText = text; latestText = text;
if (
processing &&
text !== lastPlainEmittedText &&
!tokenizationCache.has(normalizeSubtitleCacheKey(text))
) {
deps.emitSubtitle({ text, tokens: null });
lastPlainEmittedText = text;
}
processLatest(); processLatest();
}, },
refreshCurrentSubtitle: (textOverride?: string) => { refreshCurrentSubtitle: (textOverride?: string) => {
@@ -169,13 +230,12 @@ export function createSubtitleProcessingController(
latestText = text; latestText = text;
lastEmittedText = text; lastEmittedText = text;
lastEmittedGeneration = cacheGeneration; lastEmittedGeneration = cacheGeneration;
lastPlainEmittedText = null;
return cached; return cached;
}, },
hasCachedSubtitle: (text: string) => { hasCachedSubtitle: (text: string) => {
return tokenizationCache.has(normalizeSubtitleCacheKey(text)); const cacheKey = normalizeSubtitleCacheKey(text);
}, return cacheKey.length > 0 && tokenizationCache.has(cacheKey);
isCacheFull: () => {
return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT;
}, },
}; };
} }
+35
View File
@@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
isSubtitleAnnotationUpgrade,
serializeInitialSubtitleWebsocketMessage, serializeInitialSubtitleWebsocketMessage,
serializeSubtitleMarkup, serializeSubtitleMarkup,
serializeSubtitleWebsocketMessage, serializeSubtitleWebsocketMessage,
@@ -13,6 +14,40 @@ const frequencyOptions = {
mode: 'banded' as const, mode: 'banded' as const,
}; };
test('annotation upgrade requires matching text and cue timing', () => {
const current: SubtitleData = {
text: '字幕',
tokens: null,
startTime: 10,
endTime: 12,
};
assert.equal(
isSubtitleAnnotationUpgrade(current, {
...current,
tokens: [],
}),
true,
);
assert.equal(
isSubtitleAnnotationUpgrade(current, {
...current,
tokens: [],
startTime: 11,
}),
false,
);
assert.equal(
isSubtitleAnnotationUpgrade(current, {
...current,
text: '次の字幕',
tokens: [],
}),
false,
);
assert.equal(isSubtitleAnnotationUpgrade(current, current), false);
});
test('serializeSubtitleMarkup escapes plain text and preserves line breaks', () => { test('serializeSubtitleMarkup escapes plain text and preserves line breaks', () => {
const payload: SubtitleData = { const payload: SubtitleData = {
text: 'a < b\nx & y', text: 'a < b\nx & y',
+14
View File
@@ -20,6 +20,20 @@ export type SubtitleWebsocketFrequencyOptions = {
export type SubtitleWebsocketPayloadMode = 'plain' | 'annotated'; export type SubtitleWebsocketPayloadMode = 'plain' | 'annotated';
export function isSubtitleAnnotationUpgrade(
current: SubtitleData | null,
next: SubtitleData,
): boolean {
return (
current !== null &&
current.tokens === null &&
next.tokens !== null &&
current.text === next.text &&
current.startTime === next.startTime &&
current.endTime === next.endTime
);
}
type SubtitleWebsocketMessageOptions = { type SubtitleWebsocketMessageOptions = {
payloadMode?: SubtitleWebsocketPayloadMode; payloadMode?: SubtitleWebsocketPayloadMode;
}; };
+4 -2
View File
@@ -1651,9 +1651,11 @@ test('tokenizeSubtitle clears JLPT level from standalone Yomitan particle token'
assert.equal(result.tokens?.[0]?.jlptLevel, undefined); assert.equal(result.tokens?.[0]?.jlptLevel, undefined);
}); });
test('tokenizeSubtitle returns null tokens for empty normalized text', async () => { test('tokenizeSubtitle returns the normalized text when it comes out empty', async () => {
// Handing back the original would push whatever normalization dropped into app state
// as if it were subtitle text.
const result = await tokenizeSubtitle(' \\n ', makeDeps()); const result = await tokenizeSubtitle(' \\n ', makeDeps());
assert.deepEqual(result, { text: ' \\n ', tokens: null }); assert.deepEqual(result, { text: '', tokens: null });
}); });
test('tokenizeSubtitle normalizes newlines before Yomitan parse request', async () => { test('tokenizeSubtitle normalizes newlines before Yomitan parse request', async () => {
+7 -6
View File
@@ -27,6 +27,7 @@ import {
} from './tokenizer/yomitan-parser-runtime'; } from './tokenizer/yomitan-parser-runtime';
import type { YomitanTermFrequency } from './tokenizer/yomitan-parser-runtime'; import type { YomitanTermFrequency } from './tokenizer/yomitan-parser-runtime';
import { isKanaChar } from './tokenizer/token-classification'; import { isKanaChar } from './tokenizer/token-classification';
import { normalizePlainSubtitleText } from './ass-text';
const logger = createLogger('main:tokenizer'); const logger = createLogger('main:tokenizer');
@@ -858,14 +859,14 @@ export async function tokenizeSubtitle(
text: string, text: string,
deps: TokenizerServiceDeps, deps: TokenizerServiceDeps,
): Promise<SubtitleData> { ): Promise<SubtitleData> {
const displayText = text const displayText = normalizePlainSubtitleText(text);
.replace(/\r\n/g, '\n')
.replace(/\\N/g, '\n')
.replace(/\\n/g, '\n')
.trim();
// 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.
// Return the normalized form anyway: handing back the original would put a blank line
// into application state as if it were subtitle text.
if (!displayText) { if (!displayText) {
return { text, tokens: null }; return { text: displayText, tokens: null };
} }
const tokenizeText = displayText const tokenizeText = displayText
+2 -2
View File
@@ -6,7 +6,7 @@ import * as os from 'node:os';
import * as path from 'node:path'; import * as path from 'node:path';
import type { YoutubeMediaCacheMode } from '../../../types/integrations'; import type { YoutubeMediaCacheMode } from '../../../types/integrations';
import { getYoutubeYtDlpCommand } from './ytdlp-command'; import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
type MediaCacheSessionState = 'running' | 'ready' | 'failed'; type MediaCacheSessionState = 'running' | 'ready' | 'failed';
@@ -88,7 +88,7 @@ function normalizeMaxHeight(maxHeight: number | undefined): number {
function createYtDlpArgs(url: string, outputTemplate: string, maxHeight?: number): string[] { function createYtDlpArgs(url: string, outputTemplate: string, maxHeight?: number): string[] {
return [ return [
'--no-playlist', YTDLP_SINGLE_VIDEO_ARG,
'--no-warnings', '--no-warnings',
'--force-ipv4', '--force-ipv4',
'--retries', '--retries',
+14 -6
View File
@@ -1,6 +1,6 @@
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import type { YoutubeVideoMetadata } from '../immersion-tracker/types'; import type { YoutubeVideoMetadata } from '../immersion-tracker/types';
import { getYoutubeYtDlpCommand } from './ytdlp-command'; import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000; const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000;
@@ -85,15 +85,23 @@ function pickChannelThumbnail(thumbnails: YtDlpThumbnail[] | undefined): string
return null; return null;
} }
export async function probeYoutubeVideoMetadata( export function buildYoutubeMetadataProbeArgs(targetUrl: string): string[] {
targetUrl: string, return [
): Promise<YoutubeVideoMetadata | null> { YTDLP_SINGLE_VIDEO_ARG,
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
'--dump-single-json', '--dump-single-json',
'--no-warnings', '--no-warnings',
'--skip-download', '--skip-download',
targetUrl, targetUrl,
]); ];
}
export async function probeYoutubeVideoMetadata(
targetUrl: string,
): Promise<YoutubeVideoMetadata | null> {
const { stdout } = await runCapture(
getYoutubeYtDlpCommand(),
buildYoutubeMetadataProbeArgs(targetUrl),
);
let info: YtDlpYoutubeMetadata; let info: YtDlpYoutubeMetadata;
try { try {
info = JSON.parse(stdout) as YtDlpYoutubeMetadata; info = JSON.parse(stdout) as YtDlpYoutubeMetadata;
@@ -1,5 +1,5 @@
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { getYoutubeYtDlpCommand } from './ytdlp-command'; import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000; const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000;
const DEFAULT_PLAYBACK_FORMAT = 'b'; const DEFAULT_PLAYBACK_FORMAT = 'b';
@@ -85,17 +85,18 @@ function runCapture(
}); });
} }
export function buildYoutubePlaybackResolveArgs(targetUrl: string, format: string): string[] {
return [YTDLP_SINGLE_VIDEO_ARG, '--get-url', '--no-warnings', '-f', format, targetUrl];
}
export async function resolveYoutubePlaybackUrl( export async function resolveYoutubePlaybackUrl(
targetUrl: string, targetUrl: string,
format = DEFAULT_PLAYBACK_FORMAT, format = DEFAULT_PLAYBACK_FORMAT,
): Promise<string> { ): Promise<string> {
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [ const { stdout } = await runCapture(
'--get-url', getYoutubeYtDlpCommand(),
'--no-warnings', buildYoutubePlaybackResolveArgs(targetUrl, format),
'-f', );
format,
targetUrl,
]);
const playbackUrl = const playbackUrl =
stdout stdout
.split(/\r?\n/) .split(/\r?\n/)
+3 -3
View File
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import type { YoutubeTrackOption } from './track-probe'; import type { YoutubeTrackOption } from './track-probe';
import { getYoutubeYtDlpCommand } from './ytdlp-command'; import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
import { import {
convertYoutubeTimedTextToVtt, convertYoutubeTimedTextToVtt,
isYoutubeTimedTextExtension, isYoutubeTimedTextExtension,
@@ -126,14 +126,14 @@ function pickLatestSubtitleFileForLanguage(
return candidates[0] ?? null; return candidates[0] ?? null;
} }
function buildDownloadArgs(input: { export function buildDownloadArgs(input: {
targetUrl: string; targetUrl: string;
outputTemplate: string; outputTemplate: string;
sourceLanguages: string[]; sourceLanguages: string[];
includeAutoSubs: boolean; includeAutoSubs: boolean;
includeManualSubs: boolean; includeManualSubs: boolean;
}): string[] { }): string[] {
const args = ['--skip-download', '--no-warnings']; const args = [YTDLP_SINGLE_VIDEO_ARG, '--skip-download', '--no-warnings'];
if (input.includeAutoSubs) { if (input.includeAutoSubs) {
args.push('--write-auto-subs'); args.push('--write-auto-subs');
} }
+9 -6
View File
@@ -1,7 +1,7 @@
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import type { YoutubeTrackOption } from '../../../types'; import type { YoutubeTrackOption } from '../../../types';
import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels'; import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels';
import { getYoutubeYtDlpCommand } from './ytdlp-command'; import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000; const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000;
@@ -111,12 +111,15 @@ function toTracks(entries: Record<string, YtDlpSubtitleEntry> | undefined, kind:
export type { YoutubeTrackOption }; export type { YoutubeTrackOption };
export function buildYoutubeTrackProbeArgs(targetUrl: string): string[] {
return [YTDLP_SINGLE_VIDEO_ARG, '--dump-single-json', '--no-warnings', targetUrl];
}
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> { export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [ const { stdout } = await runCapture(
'--dump-single-json', getYoutubeYtDlpCommand(),
'--no-warnings', buildYoutubeTrackProbeArgs(targetUrl),
targetUrl, );
]);
const trimmedStdout = stdout.trim(); const trimmedStdout = stdout.trim();
if (!trimmedStdout) { if (!trimmedStdout) {
throw new Error('yt-dlp returned empty output while probing subtitle tracks'); throw new Error('yt-dlp returned empty output while probing subtitle tracks');
@@ -4,6 +4,13 @@ import path from 'node:path';
const DEFAULT_YTDLP_COMMAND = 'yt-dlp'; const DEFAULT_YTDLP_COMMAND = 'yt-dlp';
const WINDOWS_YTDLP_COMMANDS = ['yt-dlp.cmd', 'yt-dlp.exe', 'yt-dlp']; const WINDOWS_YTDLP_COMMANDS = ['yt-dlp.cmd', 'yt-dlp.exe', 'yt-dlp'];
/**
* yt-dlp expands `list=`/`index=` URL params into the whole playlist unless told not to, which
* makes single-video extraction hang (e.g. a full Watch Later list) until our timeouts fire.
* Every yt-dlp invocation targeting one video must include this.
*/
export const YTDLP_SINGLE_VIDEO_ARG = '--no-playlist';
function resolveFromPath(commandName: string): string | null { function resolveFromPath(commandName: string): string | null {
if (!process.env.PATH) { if (!process.env.PATH) {
return null; return null;
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildYoutubeMetadataProbeArgs } from './metadata-probe';
import { buildYoutubePlaybackResolveArgs } from './playback-resolve';
import { buildDownloadArgs } from './track-download';
import { buildYoutubeTrackProbeArgs } from './track-probe';
import { YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
// Regression guard for issue #179: a `list=`/`index=` URL made yt-dlp enumerate the whole
// playlist (e.g. Watch Later) and blow past our 15s timeouts on every single-video call.
const PLAYLIST_URL = 'https://www.youtube.com/watch?v=LKfWC6CgFng&list=WL&index=3';
const cases: Array<{ name: string; args: string[] }> = [
{ name: 'track probe', args: buildYoutubeTrackProbeArgs(PLAYLIST_URL) },
{ name: 'metadata probe', args: buildYoutubeMetadataProbeArgs(PLAYLIST_URL) },
{ name: 'playback resolve', args: buildYoutubePlaybackResolveArgs(PLAYLIST_URL, 'b') },
{
name: 'subtitle download',
args: buildDownloadArgs({
targetUrl: PLAYLIST_URL,
outputTemplate: '/tmp/out.%(ext)s',
sourceLanguages: ['ja'],
includeAutoSubs: true,
includeManualSubs: false,
}),
},
];
test('YTDLP_SINGLE_VIDEO_ARG is the yt-dlp flag that disables playlist expansion', () => {
assert.equal(YTDLP_SINGLE_VIDEO_ARG, '--no-playlist');
});
for (const { name, args } of cases) {
test(`${name} passes --no-playlist for playlist-scoped URLs`, () => {
assert.ok(args.includes('--no-playlist'), `${name} args: ${args.join(' ')}`);
assert.equal(args.at(-1), PLAYLIST_URL);
});
}
+16 -2
View File
@@ -295,6 +295,7 @@ import {
importYomitanDictionaryFromZip, importYomitanDictionaryFromZip,
initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore, initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore,
initializeOverlayRuntime as initializeOverlayRuntimeCore, initializeOverlayRuntime as initializeOverlayRuntimeCore,
isSubtitleAnnotationUpgrade,
isOverlayWindowContentReady, isOverlayWindowContentReady,
jellyfinTicksToSecondsRuntime, jellyfinTicksToSecondsRuntime,
listJellyfinItemsRuntime, listJellyfinItemsRuntime,
@@ -1817,6 +1818,8 @@ function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
} }
function emitSubtitlePayload(payload: SubtitleData): void { function emitSubtitlePayload(payload: SubtitleData): void {
const timedPayload = withCurrentSubtitleTiming(payload); const timedPayload = withCurrentSubtitleTiming(payload);
const currentSubtitleData = appState.currentSubtitleData;
const isAnnotationUpgrade = isSubtitleAnnotationUpgrade(currentSubtitleData, timedPayload);
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary; const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
const frequencyOptions = { const frequencyOptions = {
enabled: frequencyDictionary.enabled, enabled: frequencyDictionary.enabled,
@@ -1825,7 +1828,9 @@ function emitSubtitlePayload(payload: SubtitleData): void {
}; };
appState.currentSubtitleData = timedPayload; appState.currentSubtitleData = timedPayload;
overlayManager.broadcastToOverlayWindows('subtitle:set', timedPayload); overlayManager.broadcastToOverlayWindows('subtitle:set', timedPayload);
if (!isAnnotationUpgrade) {
subtitleWsService.broadcast(timedPayload, frequencyOptions); subtitleWsService.broadcast(timedPayload, frequencyOptions);
}
annotationSubtitleWsService.broadcast(timedPayload, frequencyOptions); annotationSubtitleWsService.broadcast(timedPayload, frequencyOptions);
autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true }); autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true });
subtitlePrefetchService?.resume(); subtitlePrefetchService?.resume();
@@ -1955,7 +1960,6 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
subtitleProcessingController.preCacheTokenization(text, data); subtitleProcessingController.preCacheTokenization(text, data);
}, },
hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text), hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text),
isCacheFull: () => subtitleProcessingController.isCacheFull(),
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message), logWarn: (message) => logger.warn(message),
onParsedSubtitleCuesChanged: (cues, sourceKey) => { onParsedSubtitleCuesChanged: (cues, sourceKey) => {
@@ -1982,15 +1986,22 @@ const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSid
getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg', getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg',
extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) => extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) =>
extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track), extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track),
logDebug: (message) => logger.debug(message),
}); });
const refreshSubtitlePrefetchFromActiveTrackHandler = const refreshSubtitlePrefetchFromActiveTrackHandler =
createRefreshSubtitlePrefetchFromActiveTrackHandler({ createRefreshSubtitlePrefetchFromActiveTrackHandler({
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
getLastObservedTimePos: () => lastObservedTimePos, getLastObservedTimePos: () => lastObservedTimePos,
shouldKeepExistingCuesOnMissingSource: (videoPath) => isYoutubeMediaPath(videoPath), // Remote media has no extractable on-disk track to fall back to, so a transient
// resolve miss (sid briefly 'no', a cycle onto an embedded stream track) would
// otherwise drop a working cue list for the rest of the episode.
shouldKeepExistingCuesOnMissingSource: (videoPath) =>
isYoutubeMediaPath(videoPath) || isRemoteMediaPath(videoPath),
subtitlePrefetchInitController, subtitlePrefetchInitController,
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input), resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
logDebug: (message) => logger.debug(message),
logWarn: (message) => logger.warn(message),
}); });
const subtitlePrefetchRuntime = { const subtitlePrefetchRuntime = {
@@ -2994,6 +3005,8 @@ const {
streamIndex, streamIndex,
delaySeconds, delaySeconds,
}), }),
initSubtitlePrefetch: (sourcePath) =>
subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath),
logDebug: (message, error) => { logDebug: (message, error) => {
logger.debug(message, error); logger.debug(message, error);
}, },
@@ -4366,6 +4379,7 @@ const {
refreshDiscordPresence: () => { refreshDiscordPresence: () => {
discordPresenceRuntime.publishDiscordPresence(); discordPresenceRuntime.publishDiscordPresence();
}, },
logSubtitleProcessingDebug: (message: string) => logger.debug(message),
ensureImmersionTrackerInitialized: () => { ensureImmersionTrackerInitialized: () => {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
}, },
+52
View File
@@ -176,6 +176,29 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
); );
}); });
test('remote media keeps parsed cues when the active subtitle source cannot be resolved', () => {
const source = readMainSource();
const actionBlock = source.match(
/createRefreshSubtitlePrefetchFromActiveTrackHandler\(\{(?<body>[\s\S]*?)\n \}\);/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /isYoutubeMediaPath\(videoPath\) \|\| isRemoteMediaPath\(videoPath\)/);
});
test('jellyfin subtitle preload seeds the tokenization prefetch directly', () => {
const source = readMainSource();
const actionBlock = source.match(
/preloadJellyfinExternalSubtitlesMainDeps:\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(
actionBlock,
/initSubtitlePrefetch: \(sourcePath\) =>\s*subtitlePrefetchRuntime\.refreshSubtitleSidebarFromSource\(sourcePath\),/,
);
});
test('update overlay notification action triggers install flow', () => { test('update overlay notification action triggers install flow', () => {
const source = readMainSource(); const source = readMainSource();
const runtimeSource = readSource('src/main/runtime/overlay-notifications-runtime.ts'); const runtimeSource = readSource('src/main/runtime/overlay-notifications-runtime.ts');
@@ -590,6 +613,35 @@ test('subtitle broadcasts share one frequency options snapshot per emitted paylo
); );
}); });
test('annotation upgrades skip the duplicate basic websocket event', () => {
const source = readMainSource();
const emitBlock = source.match(
/function emitSubtitlePayload\(payload: SubtitleData\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(emitBlock);
assert.match(
emitBlock,
/const isAnnotationUpgrade = isSubtitleAnnotationUpgrade\(currentSubtitleData, timedPayload\);/,
);
assert.match(
emitBlock,
/if \(!isAnnotationUpgrade\) \{\s+subtitleWsService\.broadcast\(timedPayload, frequencyOptions\);\s+\}/,
);
assert.equal(
(emitBlock.match(/overlayManager\.broadcastToOverlayWindows\('subtitle:set'/g) ?? []).length,
1,
);
assert.equal(
(
emitBlock.match(
/annotationSubtitleWsService\.broadcast\(timedPayload, frequencyOptions\)/g,
) ?? []
).length,
1,
);
});
test('websocket frequency options callbacks each read one configuration snapshot', () => { test('websocket frequency options callbacks each read one configuration snapshot', () => {
const source = readMainSource(); const source = readMainSource();
const subtitleBlock = source.match( const subtitleBlock = source.match(
+14 -2
View File
@@ -326,7 +326,13 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
}); });
runtime.sendToActiveOverlayWindow( runtime.sendToActiveOverlayWindow(
'subsync:open-manual', 'subsync:open-manual',
{ ffsubsyncAvailable: true, sourceTracks: [] }, {
ffsubsyncAvailable: true,
videoReferenceAvailable: true,
subtitleTracks: [],
defaultReferenceTrackId: null,
defaultTargetTrackId: null,
},
{ {
restoreOnModalClose: 'subsync', restoreOnModalClose: 'subsync',
}, },
@@ -560,7 +566,13 @@ test('modal runtime notifies callers when modal input state becomes active/inact
}); });
runtime.sendToActiveOverlayWindow( runtime.sendToActiveOverlayWindow(
'subsync:open-manual', 'subsync:open-manual',
{ ffsubsyncAvailable: true, sourceTracks: [] }, {
ffsubsyncAvailable: true,
videoReferenceAvailable: true,
subtitleTracks: [],
defaultReferenceTrackId: null,
defaultTargetTrackId: null,
},
{ {
restoreOnModalClose: 'subsync', restoreOnModalClose: 'subsync',
}, },
@@ -4,7 +4,7 @@ import { composeIpcRuntimeHandlers } from './ipc-runtime-composer';
test('composeIpcRuntimeHandlers returns callable IPC handlers and registration bridge', async () => { test('composeIpcRuntimeHandlers returns callable IPC handlers and registration bridge', async () => {
let registered = false; let registered = false;
let receivedSourceTrackId: number | null | undefined; let receivedReferenceTrackId: number | null | undefined;
const composed = composeIpcRuntimeHandlers({ const composed = composeIpcRuntimeHandlers({
mpvCommandMainDeps: { mpvCommandMainDeps: {
@@ -25,7 +25,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
}, },
handleMpvCommandFromIpcRuntime: () => {}, handleMpvCommandFromIpcRuntime: () => {},
runSubsyncManualFromIpc: async (request) => { runSubsyncManualFromIpc: async (request) => {
receivedSourceTrackId = request.sourceTrackId; receivedReferenceTrackId = request.referenceTrackId;
return { return {
ok: true, ok: true,
message: 'ok', message: 'ok',
@@ -124,10 +124,10 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
const result = await composed.runSubsyncManualFromIpc({ const result = await composed.runSubsyncManualFromIpc({
engine: 'alass', engine: 'alass',
sourceTrackId: 7, referenceTrackId: 7,
}); });
assert.deepEqual(result, { ok: true, message: 'ok' }); assert.deepEqual(result, { ok: true, message: 'ok' });
assert.equal(receivedSourceTrackId, 7); assert.equal(receivedReferenceTrackId, 7);
composed.registerIpcRuntimeHandlers(); composed.registerIpcRuntimeHandlers();
assert.equal(registered, true); assert.equal(registered, true);
@@ -73,6 +73,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
config.ankiConnect.fields.miscInfo = 'MiscInfoCustom'; config.ankiConnect.fields.miscInfo = 'MiscInfoCustom';
config.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom'; config.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom';
config.ankiConnect.isKiku.fieldGrouping = 'manual'; config.ankiConnect.isKiku.fieldGrouping = 'manual';
config.ankiConnect.lapisKiku.wordCardKind = 'click';
config.logging.level = 'debug'; config.logging.level = 'debug';
config.logging.rotation = 14; config.logging.rotation = 14;
config.logging.files.mpv = true; config.logging.files.mpv = true;
@@ -114,6 +115,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
'ankiConnect.fields.miscInfo', 'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel', 'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping', 'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.lapisKiku.wordCardKind',
'logging.level', 'logging.level',
'logging.rotation', 'logging.rotation',
'logging.files.mpv', 'logging.files.mpv',
@@ -138,6 +140,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
}, },
isLapis: { sentenceCardModel: 'Sentence Card Custom' }, isLapis: { sentenceCardModel: 'Sentence Card Custom' },
isKiku: { fieldGrouping: 'manual' }, isKiku: { fieldGrouping: 'manual' },
lapisKiku: { wordCardKind: 'click' },
}, },
]); ]);
assert.ok(calls.includes('invalidate:tokens')); assert.ok(calls.includes('invalidate:tokens'));
@@ -134,6 +134,9 @@ function buildAnkiRuntimeConfigPatch(
if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) { if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) {
patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping }; patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping };
} }
if (diff.hotReloadFields.includes('ankiConnect.lapisKiku.wordCardKind')) {
patch.lapisKiku = { wordCardKind: config.ankiConnect.lapisKiku.wordCardKind };
}
return Object.keys(patch).length > 0 ? patch : null; return Object.keys(patch).length > 0 ? patch : null;
} }
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
getPlaybackFeedbackNotificationOptions, getPlaybackFeedbackNotificationOptions,
getSubsyncStatusNotificationOptions,
getYoutubeFlowStatusNotificationOptions, getYoutubeFlowStatusNotificationOptions,
notifyConfiguredStatus, notifyConfiguredStatus,
} from './configured-status-notification'; } from './configured-status-notification';
@@ -10,7 +11,7 @@ import { createOverlayNotificationDelivery } from './overlay-notification-delive
test('notifyConfiguredStatus routes both to overlay and system without osd', () => { test('notifyConfiguredStatus routes both to overlay and system without osd', () => {
const calls: string[] = []; const calls: string[] = [];
notifyConfiguredStatus('Subsync: choose engine and source', { notifyConfiguredStatus('Subsync: choose engine and subtitles', {
getNotificationType: () => 'both', getNotificationType: () => 'both',
showOsd: (message) => { showOsd: (message) => {
calls.push(`osd:${message}`); calls.push(`osd:${message}`);
@@ -24,8 +25,8 @@ test('notifyConfiguredStatus routes both to overlay and system without osd', ()
}); });
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'overlay::SubMiner:Subsync: choose engine and source:info:auto', 'overlay::SubMiner:Subsync: choose engine and subtitles:info:auto',
'desktop:SubMiner:Subsync: choose engine and source', 'desktop:SubMiner:Subsync: choose engine and subtitles',
]); ]);
}); });
@@ -158,6 +159,64 @@ test('notifyConfiguredStatus can suppress desktop delivery for progress ticks',
assert.deepEqual(calls, ['overlay:subsync-status:Subsync:Subsync: syncing |:progress:pin']); assert.deepEqual(calls, ['overlay:subsync-status:Subsync:Subsync: syncing |:progress:pin']);
}); });
test('subsync progress keeps the osd spinner frame but strips it from the overlay card', () => {
const calls: string[] = [];
for (const frame of ['|', '/', '-', '\\']) {
const message = `Subsync: syncing ${frame}`;
notifyConfiguredStatus(
message,
{
getNotificationType: () => 'both',
showOsd: (osdMessage) => {
calls.push(`osd:${osdMessage}`);
},
showOverlayNotification: (payload) =>
calls.push(
`overlay:${payload.body}:${payload.variant}:${payload.persistent ? 'pin' : 'auto'}`,
),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
},
getSubsyncStatusNotificationOptions(message),
);
}
assert.deepEqual(calls, [
'overlay:Subsync: syncing:progress:pin',
'overlay:Subsync: syncing:progress:pin',
'overlay:Subsync: syncing:progress:pin',
'overlay:Subsync: syncing:progress:pin',
]);
calls.length = 0;
notifyConfiguredStatus(
'Subsync: syncing /',
{
getNotificationType: () => 'osd',
showOsd: (osdMessage) => {
calls.push(`osd:${osdMessage}`);
},
showOverlayNotification: (payload) => calls.push(`overlay:${payload.body}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
},
getSubsyncStatusNotificationOptions('Subsync: syncing /'),
);
assert.deepEqual(calls, ['osd:Subsync: syncing /']);
});
test('subsync result notifications keep their message intact', () => {
assert.equal(
getSubsyncStatusNotificationOptions('Subtitle synchronized with ffsubsync').overlayBody,
'Subtitle synchronized with ffsubsync',
);
const failure = getSubsyncStatusNotificationOptions('ffsubsync synchronization failed: boom');
assert.equal(failure.variant, 'error');
assert.equal(failure.overlayBody, 'ffsubsync synchronization failed: boom');
});
test('notifyConfiguredStatus routes feedback through overlay without desktop delivery', () => { test('notifyConfiguredStatus routes feedback through overlay without desktop delivery', () => {
const calls: string[] = []; const calls: string[] = [];
@@ -12,6 +12,8 @@ export interface ConfiguredStatusNotificationDeps {
export interface ConfiguredStatusNotificationOptions { export interface ConfiguredStatusNotificationOptions {
id?: string; id?: string;
/** Overrides the overlay card body (the OSD/desktop paths keep the raw message). */
overlayBody?: string;
title?: string; title?: string;
variant?: OverlayNotificationPayload['variant']; variant?: OverlayNotificationPayload['variant'];
persistent?: boolean; persistent?: boolean;
@@ -31,6 +33,23 @@ export function getPlaybackFeedbackNotificationOptions(
return {}; return {};
} }
export function getSubsyncStatusNotificationOptions(
message: string,
): ConfiguredStatusNotificationOptions {
const syncing = message.startsWith('Subsync: syncing');
const failed = message.toLowerCase().includes('failed');
return {
id: 'subsync-status',
title: 'Subsync',
// The overlay card renders its own animated spinner, so drop the ASCII
// spinner frame that the OSD path still needs.
overlayBody: syncing ? message.replace(/\s+[|/\-\\]$/, '') : message,
variant: failed ? 'error' : syncing ? 'progress' : 'info',
persistent: syncing,
desktop: !syncing,
};
}
export function getYoutubeFlowStatusNotificationOptions( export function getYoutubeFlowStatusNotificationOptions(
message: string, message: string,
): ConfiguredStatusNotificationOptions { ): ConfiguredStatusNotificationOptions {
@@ -74,7 +93,7 @@ export function notifyConfiguredStatus(
deps.showOverlayNotification({ deps.showOverlayNotification({
id: options.id, id: options.id,
title: options.title ?? 'SubMiner', title: options.title ?? 'SubMiner',
body: message, body: options.overlayBody ?? message,
variant: options.variant ?? 'info', variant: options.variant ?? 'info',
persistent: options.persistent ?? false, persistent: options.persistent ?? false,
}); });
@@ -28,6 +28,9 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
? (itemId, streamIndex, delaySeconds) => ? (itemId, streamIndex, delaySeconds) =>
deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds) deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds)
: undefined, : undefined,
initSubtitlePrefetch: deps.initSubtitlePrefetch
? (sourcePath) => deps.initSubtitlePrefetch!(sourcePath)
: undefined,
logDebug: (message: string, error: unknown) => deps.logDebug(message, error), logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
}); });
} }
@@ -40,6 +40,9 @@ function makeDeps(overrides: {
>[0]['setActiveSubtitleDelayKey']; >[0]['setActiveSubtitleDelayKey'];
loadSubtitleSourceText?: (source: string) => Promise<string>; loadSubtitleSourceText?: (source: string) => Promise<string>;
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void; saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void;
initSubtitlePrefetch?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['initSubtitlePrefetch'];
logDebug?: Parameters<typeof createPreloadJellyfinExternalSubtitlesHandler>[0]['logDebug']; logDebug?: Parameters<typeof createPreloadJellyfinExternalSubtitlesHandler>[0]['logDebug'];
}) { }) {
return { return {
@@ -58,6 +61,7 @@ function makeDeps(overrides: {
setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey, setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey,
loadSubtitleSourceText: overrides.loadSubtitleSourceText, loadSubtitleSourceText: overrides.loadSubtitleSourceText,
saveSubtitleDelay: overrides.saveSubtitleDelay, saveSubtitleDelay: overrides.saveSubtitleDelay,
initSubtitlePrefetch: overrides.initSubtitlePrefetch,
logDebug: overrides.logDebug ?? (() => {}), logDebug: overrides.logDebug ?? (() => {}),
}; };
} }
@@ -134,6 +138,92 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa
]); ]);
}); });
test('preload jellyfin subtitles starts prefetch for the selected japanese track', async () => {
const prefetched: string[] = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
{ index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 5,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 6,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
],
}),
cacheSubtitleTrack: async (track) => ({
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
}),
initSubtitlePrefetch: (sourcePath) => {
prefetched.push(sourcePath);
},
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(prefetched, ['/tmp/subminer-jellyfin-subtitles/0.srt']);
});
test('preload jellyfin subtitles survives prefetch start failures', async () => {
const logs: string[] = [];
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 5,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
],
}),
sendMpvCommand: (command) => commands.push(command),
cacheSubtitleTrack: async (track) => ({
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
}),
initSubtitlePrefetch: async () => {
throw new Error('parse failed');
},
logDebug: (message) => logs.push(message),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(logs, ['Failed to start subtitle prefetch for Jellyfin subtitle']);
assert.ok(
commands.some((command) => command[0] === 'set_property' && command[1] === 'sid'),
'subtitle selection still happens when prefetch start fails',
);
});
test('preload jellyfin subtitles stages tracks without temporary subtitle selection', async () => { test('preload jellyfin subtitles stages tracks without temporary subtitle selection', async () => {
const commands: Array<Array<string | number>> = []; const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler( const preload = createPreloadJellyfinExternalSubtitlesHandler(
@@ -320,6 +320,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void; setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void;
loadSubtitleSourceText?: (source: string) => Promise<string>; loadSubtitleSourceText?: (source: string) => Promise<string>;
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void; saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void;
initSubtitlePrefetch?: (sourcePath: string) => void | Promise<void>;
logDebug: (message: string, error: unknown) => void; logDebug: (message: string, error: unknown) => void;
}): PreloadJellyfinExternalSubtitlesHandler { }): PreloadJellyfinExternalSubtitlesHandler {
const activeCacheDirs = new Set<string>(); const activeCacheDirs = new Set<string>();
@@ -329,6 +330,18 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
deps.sendMpvCommand(['set_property', 'sub-delay', 0]); deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
} }
// mpv's sid property-change is the only thing that normally starts prefetching, so a
// coalesced or missed event leaves the whole episode uncached. The downloaded path is
// known here, so seed the pipeline directly instead of waiting on the observer.
function startSubtitlePrefetchForCachedTrack(sourcePath: string): void {
if (!deps.initSubtitlePrefetch) return;
void Promise.resolve()
.then(() => deps.initSubtitlePrefetch!(sourcePath))
.catch((error) => {
deps.logDebug('Failed to start subtitle prefetch for Jellyfin subtitle', error);
});
}
function cleanupActiveCache(): void { function cleanupActiveCache(): void {
const dirs = [...activeCacheDirs]; const dirs = [...activeCacheDirs];
if (dirs.length === 0) return; if (dirs.length === 0) return;
@@ -438,6 +451,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
} }
} }
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]); deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
startSubtitlePrefetchForCachedTrack(selectedCachedTrack.path);
} else { } else {
deps.setActiveSubtitleDelayKey?.(null); deps.setActiveSubtitleDelayKey?.(null);
resetManagedSubtitleDelay(); resetManagedSubtitleDelay();
@@ -336,8 +336,8 @@ test('tick only writes interaction state on change', () => {
state.active = active; state.active = active;
}, },
}); });
tickLinuxOverlayPointerInteraction(deps); // off→on tickLinuxOverlayPointerInteraction(deps, 'linux'); // off→on
tickLinuxOverlayPointerInteraction(deps); // no change tickLinuxOverlayPointerInteraction(deps, 'linux'); // no change
assert.deepEqual(calls, [true]); assert.deepEqual(calls, [true]);
}); });
@@ -352,7 +352,7 @@ test('tick reapplies an unchanged inactive state when the window passthrough sta
}, },
}); });
tickLinuxOverlayPointerInteraction(deps); tickLinuxOverlayPointerInteraction(deps, 'linux');
assert.deepEqual(calls, [false]); assert.deepEqual(calls, [false]);
}); });
@@ -363,7 +363,7 @@ test('tick does not flip state when suspended (returns null)', () => {
shouldSuspend: () => true, shouldSuspend: () => true,
setInteractionActive: (active) => calls.push(active), setInteractionActive: (active) => calls.push(active),
}); });
tickLinuxOverlayPointerInteraction(deps); tickLinuxOverlayPointerInteraction(deps, 'linux');
assert.deepEqual(calls, []); assert.deepEqual(calls, []);
}); });
@@ -379,10 +379,26 @@ test('tick clears active hover while a separate SubMiner window suppresses overl
}); });
state.active = true; state.active = true;
tickLinuxOverlayPointerInteraction(deps); tickLinuxOverlayPointerInteraction(deps, 'linux');
assert.deepEqual(calls, [false]); assert.deepEqual(calls, [false]);
}); });
test('tick never clears interaction state on macOS/Windows, where renderer hover owns it', () => {
for (const platform of ['darwin', 'win32'] as const) {
const calls: boolean[] = [];
// Pointer is off the measured subtitle rect (e.g. sitting on a Yomitan popup) while the
// renderer has marked the overlay interactive.
const { deps } = makeDeps({
getCursorScreenPoint: () => ({ x: 200, y: 200 }),
getInteractionActive: () => true,
setInteractionActive: (active) => calls.push(active),
});
tickLinuxOverlayPointerInteraction(deps, platform);
assert.deepEqual(calls, [], `expected no interaction writes on ${platform}`);
}
});
test('tick skips cursor-driven mouse-ignore toggles when Linux input shape owns hit rects', () => { test('tick skips cursor-driven mouse-ignore toggles when Linux input shape owns hit rects', () => {
const calls: boolean[] = []; const calls: boolean[] = [];
const { deps } = makeDeps({ const { deps } = makeDeps({
@@ -391,7 +407,7 @@ test('tick skips cursor-driven mouse-ignore toggles when Linux input shape owns
setInteractionActive: (active) => calls.push(active), setInteractionActive: (active) => calls.push(active),
}); });
tickLinuxOverlayPointerInteraction(deps); tickLinuxOverlayPointerInteraction(deps, 'linux');
assert.deepEqual(calls, []); assert.deepEqual(calls, []);
}); });
@@ -270,7 +270,16 @@ export function resolveDesiredOverlayInteractive(
); );
} }
export function tickLinuxOverlayPointerInteraction(deps: LinuxOverlayPointerInteractionDeps): void { export function tickLinuxOverlayPointerInteraction(
deps: LinuxOverlayPointerInteractionDeps,
platform: NodeJS.Platform = process.platform,
): void {
// Linux-only. Windows/macOS drive interaction state from renderer hover (setIgnoreMouseEvents),
// which knows about Yomitan popups and modals that sit off the measured subtitle rects. This
// cursor poll only hit-tests those rects, so running it elsewhere would clear interaction state
// (and re-enable window passthrough) whenever a measurement lands while the pointer is on a
// popup, swallowing popup clicks and scroll.
if (platform !== 'linux') return;
if (deps.shouldUseInputShape?.()) return; if (deps.shouldUseInputShape?.()) return;
const desired = resolveDesiredOverlayInteractive(deps); const desired = resolveDesiredOverlayInteractive(deps);
if (desired === null) return; if (desired === null) return;
@@ -70,6 +70,24 @@ test('subtitle change handler broadcasts cached annotated payload immediately wh
]); ]);
}); });
test('subtitle change handler logs debug when a cached payload is emitted immediately', () => {
const debugs: string[] = [];
const handler = createHandleMpvSubtitleChangeHandler({
setCurrentSubText: () => {},
getImmediateSubtitlePayload: (text) => (text ? { text, tokens: [] } : null),
broadcastSubtitle: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
logDebug: (message) => debugs.push(message),
});
handler({ text: 'キャッシュ済みの行' });
handler({ text: '' });
assert.equal(debugs.length, 1);
assert.match(debugs[0]!, /cached subtitle/);
});
test('subtitle change handler emits cached annotation after forwarding the subtitle change', () => { test('subtitle change handler emits cached annotation after forwarding the subtitle change', () => {
const calls: string[] = []; const calls: string[] = [];
const handler = createHandleMpvSubtitleChangeHandler({ const handler = createHandleMpvSubtitleChangeHandler({
@@ -20,11 +20,15 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
broadcastSubtitle: (payload: SubtitleData) => void; broadcastSubtitle: (payload: SubtitleData) => void;
onSubtitleChange: (text: string) => void; onSubtitleChange: (text: string) => void;
refreshDiscordPresence: () => void; refreshDiscordPresence: () => void;
logDebug?: (message: string) => void;
}) { }) {
return ({ text }: { text: string }): void => { return ({ text }: { text: string }): void => {
deps.setCurrentSubText(text); deps.setCurrentSubText(text);
const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null; const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null;
if (immediatePayload) { if (immediatePayload) {
deps.logDebug?.(
`[subtitle-processing] emitted cached subtitle immediately (${text.length} chars)`,
);
deps.onSubtitleChange(text); deps.onSubtitleChange(text);
(deps.emitImmediateSubtitle ?? deps.broadcastSubtitle)(immediatePayload); (deps.emitImmediateSubtitle ?? deps.broadcastSubtitle)(immediatePayload);
} else { } else {
@@ -47,6 +47,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
emitImmediateSubtitle?: (payload: SubtitleData) => void; emitImmediateSubtitle?: (payload: SubtitleData) => void;
broadcastSubtitle: (payload: SubtitleData) => void; broadcastSubtitle: (payload: SubtitleData) => void;
onSubtitleChange: (text: string) => void; onSubtitleChange: (text: string) => void;
logSubtitleProcessingDebug?: (message: string) => void;
refreshDiscordPresence: () => void; refreshDiscordPresence: () => void;
setCurrentSubAssText: (text: string) => void; setCurrentSubAssText: (text: string) => void;
@@ -123,6 +124,9 @@ export function createBindMpvMainEventHandlersHandler(deps: {
: undefined, : undefined,
broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload), broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload),
onSubtitleChange: (text) => deps.onSubtitleChange(text), onSubtitleChange: (text) => deps.onSubtitleChange(text),
logDebug: deps.logSubtitleProcessingDebug
? (message) => deps.logSubtitleProcessingDebug?.(message)
: undefined,
refreshDiscordPresence: () => deps.refreshDiscordPresence(), refreshDiscordPresence: () => deps.refreshDiscordPresence(),
}); });
const handleMpvSubtitleAssChange = createHandleMpvSubtitleAssChangeHandler({ const handleMpvSubtitleAssChange = createHandleMpvSubtitleAssChangeHandler({
@@ -54,6 +54,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null; getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
emitImmediateSubtitle?: (payload: SubtitleData) => void; emitImmediateSubtitle?: (payload: SubtitleData) => void;
onSubtitleChange: (text: string) => void; onSubtitleChange: (text: string) => void;
logSubtitleProcessingDebug?: (message: string) => void;
onSubtitleTrackChange?: (sid: number | null) => void; onSubtitleTrackChange?: (sid: number | null) => void;
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void; onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
updateCurrentMediaPath: (path: string) => void; updateCurrentMediaPath: (path: string) => void;
@@ -155,6 +156,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
broadcastSubtitle: (payload: SubtitleData) => broadcastSubtitle: (payload: SubtitleData) =>
deps.broadcastToOverlayWindows('subtitle:set', payload), deps.broadcastToOverlayWindows('subtitle:set', payload),
onSubtitleChange: (text: string) => deps.onSubtitleChange(text), onSubtitleChange: (text: string) => deps.onSubtitleChange(text),
logSubtitleProcessingDebug: deps.logSubtitleProcessingDebug
? (message: string) => deps.logSubtitleProcessingDebug!(message)
: undefined,
onSubtitleTrackChange: deps.onSubtitleTrackChange onSubtitleTrackChange: deps.onSubtitleTrackChange
? (sid: number | null) => deps.onSubtitleTrackChange!(sid) ? (sid: number | null) => deps.onSubtitleTrackChange!(sid)
: undefined, : undefined,
@@ -22,6 +22,7 @@ import { withConfiguredOverlayNotificationPosition } from './overlay-notificatio
import { createOverlayNotificationDelivery } from './overlay-notification-delivery'; import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
import { import {
getPlaybackFeedbackNotificationOptions, getPlaybackFeedbackNotificationOptions,
getSubsyncStatusNotificationOptions,
getYoutubeFlowStatusNotificationOptions, getYoutubeFlowStatusNotificationOptions,
notifyConfiguredStatus, notifyConfiguredStatus,
type ConfiguredStatusNotificationOptions, type ConfiguredStatusNotificationOptions,
@@ -195,15 +196,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
} }
function showSubsyncStatusNotification(message: string): void { function showSubsyncStatusNotification(message: string): void {
const syncing = message.startsWith('Subsync: syncing'); showConfiguredStatusNotification(message, getSubsyncStatusNotificationOptions(message));
const failed = message.toLowerCase().includes('failed');
showConfiguredStatusNotification(message, {
id: 'subsync-status',
title: 'Subsync',
variant: failed ? 'error' : syncing ? 'progress' : 'info',
persistent: syncing,
desktop: !syncing,
});
} }
function showYoutubeFlowStatusNotification(message: string): void { function showYoutubeFlowStatusNotification(message: string): void {
+7 -1
View File
@@ -5,7 +5,13 @@ import type { SubsyncManualPayload } from '../../types';
const payload: SubsyncManualPayload = { const payload: SubsyncManualPayload = {
ffsubsyncAvailable: true, ffsubsyncAvailable: true,
sourceTracks: [{ id: 2, label: 'External #2 - eng' }], videoReferenceAvailable: true,
subtitleTracks: [
{ id: 1, label: 'Internal #1 - jpn (active)' },
{ id: 2, label: 'External #2 - eng' },
],
defaultReferenceTrackId: 2,
defaultTargetTrackId: 1,
}; };
test('subsync manual open prefers dedicated modal window on first attempt', async () => { test('subsync manual open prefers dedicated modal window on first attempt', async () => {
@@ -54,7 +54,6 @@ test('latest subtitle prefetch init wins over stale async loads', async () => {
}), }),
tokenizeSubtitle: async () => null, tokenizeSubtitle: async () => null,
preCacheTokenization: () => {}, preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {}, logInfo: () => {},
logWarn: () => {}, logWarn: () => {},
}); });
@@ -99,7 +98,6 @@ test('cancelPendingInit prevents an in-flight load from attaching a stale servic
}), }),
tokenizeSubtitle: async () => null, tokenizeSubtitle: async () => null,
preCacheTokenization: () => {}, preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {}, logInfo: () => {},
logWarn: () => {}, logWarn: () => {},
}); });
@@ -137,7 +135,6 @@ test('subtitle prefetch init publishes parsed cues and clears them on cancel', a
}), }),
tokenizeSubtitle: async () => null, tokenizeSubtitle: async () => null,
preCacheTokenization: () => {}, preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {}, logInfo: () => {},
logWarn: () => {}, logWarn: () => {},
onParsedSubtitleCuesChanged: (cues) => { onParsedSubtitleCuesChanged: (cues) => {
@@ -181,7 +178,6 @@ test('subtitle prefetch init publishes the provided stable source key instead of
}), }),
tokenizeSubtitle: async () => null, tokenizeSubtitle: async () => null,
preCacheTokenization: () => {}, preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {}, logInfo: () => {},
logWarn: () => {}, logWarn: () => {},
onParsedSubtitleCuesChanged: (_cues, source) => { onParsedSubtitleCuesChanged: (_cues, source) => {
@@ -222,7 +218,6 @@ test('subtitle prefetch init clears parsed cues when initialization fails', asyn
}), }),
tokenizeSubtitle: async () => null, tokenizeSubtitle: async () => null,
preCacheTokenization: () => {}, preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {}, logInfo: () => {},
logWarn: () => {}, logWarn: () => {},
onParsedSubtitleCuesChanged: (cues) => { onParsedSubtitleCuesChanged: (cues) => {
@@ -234,3 +229,25 @@ test('subtitle prefetch init clears parsed cues when initialization fails', asyn
assert.deepEqual(cueUpdates, [null]); assert.deepEqual(cueUpdates, [null]);
}); });
test('subtitle prefetch init logs a warning when the source parses to zero cues', async () => {
const warnings: string[] = [];
const controller = createSubtitlePrefetchInitController({
getCurrentService: () => null,
setCurrentService: () => {},
loadSubtitleSourceText: async () => 'not really subtitles',
parseSubtitleCues: (): SubtitleCue[] => [],
createSubtitlePrefetchService: () => {
throw new Error('should not create a service without cues');
},
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
logInfo: () => {},
logWarn: (message) => warnings.push(message),
});
await controller.initSubtitlePrefetch('/tmp/broken.ass', 0);
assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /\[subtitle-prefetch\].*0 cues.*\/tmp\/broken\.ass/);
});
+3 -2
View File
@@ -14,7 +14,6 @@ export interface SubtitlePrefetchInitControllerDeps {
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>; tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
preCacheTokenization: (text: string, data: SubtitleData) => void; preCacheTokenization: (text: string, data: SubtitleData) => void;
hasCachedTokenization?: (text: string) => boolean; hasCachedTokenization?: (text: string) => boolean;
isCacheFull: () => boolean;
logInfo: (message: string) => void; logInfo: (message: string) => void;
logWarn: (message: string) => void; logWarn: (message: string) => void;
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void; onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
@@ -59,6 +58,9 @@ export function createSubtitlePrefetchInitController(
const cues = deps.parseSubtitleCues(content, sourcePath); const cues = deps.parseSubtitleCues(content, sourcePath);
if (revision !== initRevision || cues.length === 0) { if (revision !== initRevision || cues.length === 0) {
if (revision === initRevision) { if (revision === initRevision) {
deps.logWarn(
`[subtitle-prefetch] parsed 0 cues from ${sourcePath}; prefetch disabled for this source`,
);
deps.onParsedSubtitleCuesChanged?.(null, null); deps.onParsedSubtitleCuesChanged?.(null, null);
} }
return; return;
@@ -69,7 +71,6 @@ export function createSubtitlePrefetchInitController(
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text), tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data), preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false, hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false,
isCacheFull: () => deps.isCacheFull(),
}); });
if (revision !== initRevision) { if (revision !== initRevision) {
@@ -130,3 +130,121 @@ test('subtitle prefetch runtime does not extract internal subtitle tracks from r
assert.equal(resolved, null); assert.equal(resolved, null);
assert.equal(extracted, false); assert.equal(extracted, false);
}); });
test('subtitle prefetch refresh logs a warning when source resolution throws', async () => {
const warnings: string[] = [];
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => (name === 'path' ? '/media/video.mkv' : null),
}),
getLastObservedTimePos: () => 0,
subtitlePrefetchInitController: {
cancelPendingInit: () => {},
initSubtitlePrefetch: async () => {},
},
resolveActiveSubtitleSidebarSource: async () => {
throw new Error('ffmpeg ENOENT');
},
logWarn: (message) => warnings.push(message),
});
await refresh();
assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /\[subtitle-prefetch\].*ffmpeg ENOENT/);
});
test('subtitle prefetch refresh logs debug when mpv client is not connected', async () => {
const debugs: string[] = [];
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
getMpvClient: () => null,
getLastObservedTimePos: () => 0,
subtitlePrefetchInitController: {
cancelPendingInit: () => {},
initSubtitlePrefetch: async () => {},
},
resolveActiveSubtitleSidebarSource: async () => null,
logDebug: (message) => debugs.push(message),
});
await refresh();
assert.equal(debugs.length, 1);
assert.match(debugs[0]!, /\[subtitle-prefetch\].*not connected/);
});
test('subtitle prefetch refresh logs debug when no subtitle source resolves', async () => {
const debugs: string[] = [];
const cancels: number[] = [];
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => (name === 'path' ? '/media/video.mkv' : null),
}),
getLastObservedTimePos: () => 0,
subtitlePrefetchInitController: {
cancelPendingInit: () => {
cancels.push(1);
},
initSubtitlePrefetch: async () => {},
},
resolveActiveSubtitleSidebarSource: async () => null,
logDebug: (message) => debugs.push(message),
});
await refresh();
assert.deepEqual(cancels, [1]);
assert.equal(debugs.length, 1);
assert.match(debugs[0]!, /\[subtitle-prefetch\].*no active subtitle source/);
});
test('subtitle source resolver logs debug when internal track extraction is unavailable', async () => {
const debugs: string[] = [];
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
getFfmpegPath: () => 'ffmpeg',
extractInternalSubtitleTrack: async () => null,
logDebug: (message) => debugs.push(message),
});
const resolved = await resolveSource({
currentExternalFilenameRaw: null,
currentTrackRaw: {
type: 'sub',
id: 3,
'ff-index': 7,
codec: 'hdmv_pgs_subtitle',
},
trackListRaw: [],
sidRaw: 3,
videoPath: '/media/video.mkv',
});
assert.equal(resolved, null);
assert.equal(debugs.length, 1);
assert.match(debugs[0]!, /\[subtitle-prefetch\].*extraction.*hdmv_pgs_subtitle/);
});
test('subtitle source resolver logs debug when no active subtitle track is selected', async () => {
const debugs: string[] = [];
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
getFfmpegPath: () => 'ffmpeg',
extractInternalSubtitleTrack: async () => {
throw new Error('should not extract without a track');
},
logDebug: (message) => debugs.push(message),
});
const resolved = await resolveSource({
currentExternalFilenameRaw: null,
currentTrackRaw: null,
trackListRaw: [],
sidRaw: null,
videoPath: '/media/video.mkv',
});
assert.equal(resolved, null);
assert.equal(debugs.length, 1);
assert.match(debugs[0]!, /\[subtitle-prefetch\].*no active subtitle track/);
});
+22 -2
View File
@@ -86,6 +86,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
videoPath: string, videoPath: string,
track: MpvSubtitleTrackLike, track: MpvSubtitleTrackLike,
) => Promise<{ path: string; cleanup: () => Promise<void> } | null>; ) => Promise<{ path: string; cleanup: () => Promise<void> } | null>;
logDebug?: (message: string) => void;
}) { }) {
return async (input: { return async (input: {
currentExternalFilenameRaw: unknown; currentExternalFilenameRaw: unknown;
@@ -104,6 +105,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
const track = getActiveSubtitleTrack(input.currentTrackRaw, input.trackListRaw, input.sidRaw); const track = getActiveSubtitleTrack(input.currentTrackRaw, input.trackListRaw, input.sidRaw);
if (!track) { if (!track) {
deps.logDebug?.('[subtitle-prefetch] no active subtitle track selected yet');
return null; return null;
} }
@@ -114,6 +116,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
} }
if (isRemoteMediaPath(input.videoPath)) { if (isRemoteMediaPath(input.videoPath)) {
deps.logDebug?.('[subtitle-prefetch] skipping internal subtitle extraction for remote media');
return null; return null;
} }
@@ -123,6 +126,9 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
track, track,
); );
if (!extracted) { if (!extracted) {
deps.logDebug?.(
`[subtitle-prefetch] internal subtitle extraction unavailable (codec=${String(track.codec ?? 'unknown')}, ff-index=${String(track['ff-index'] ?? 'unknown')})`,
);
return null; return null;
} }
@@ -144,10 +150,13 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
resolveActiveSubtitleSidebarSource: ( resolveActiveSubtitleSidebarSource: (
input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0], input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0],
) => Promise<ActiveSubtitleSidebarSource | null>; ) => Promise<ActiveSubtitleSidebarSource | null>;
logDebug?: (message: string) => void;
logWarn?: (message: string) => void;
}) { }) {
return async (): Promise<void> => { return async (): Promise<void> => {
const client = deps.getMpvClient(); const client = deps.getMpvClient();
if (!client?.connected) { if (!client?.connected) {
deps.logDebug?.('[subtitle-prefetch] skipped refresh: mpv client not connected');
return; return;
} }
@@ -162,6 +171,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
]); ]);
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : ''; const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
if (!videoPath) { if (!videoPath) {
deps.logDebug?.('[subtitle-prefetch] skipped refresh: no media path');
deps.subtitlePrefetchInitController.cancelPendingInit(); deps.subtitlePrefetchInitController.cancelPendingInit();
return; return;
} }
@@ -175,8 +185,14 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
}); });
if (!resolvedSource) { if (!resolvedSource) {
if (deps.shouldKeepExistingCuesOnMissingSource?.(videoPath) === true) { if (deps.shouldKeepExistingCuesOnMissingSource?.(videoPath) === true) {
deps.logDebug?.(
'[subtitle-prefetch] no active subtitle source resolved; keeping existing cues',
);
return; return;
} }
deps.logDebug?.(
'[subtitle-prefetch] no active subtitle source resolved; cancelling prefetch',
);
deps.subtitlePrefetchInitController.cancelPendingInit(); deps.subtitlePrefetchInitController.cancelPendingInit();
return; return;
} }
@@ -190,8 +206,12 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
} finally { } finally {
await resolvedSource.cleanup?.(); await resolvedSource.cleanup?.();
} }
} catch { } catch (error) {
// Skip refresh when the track query fails. deps.logWarn?.(
`[subtitle-prefetch] failed to refresh from active track: ${
error instanceof Error ? error.message : String(error)
}`,
);
} }
}; };
} }
@@ -827,6 +827,9 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
}; };
function tickLinuxOverlayPointerInteractionNow(): void { function tickLinuxOverlayPointerInteractionNow(): void {
if (process.platform !== 'linux') {
return;
}
if (applyLinuxOverlayInputShapeFromLatestMeasurement()) { if (applyLinuxOverlayInputShapeFromLatestMeasurement()) {
return; return;
} }
+7 -3
View File
@@ -359,9 +359,13 @@
ffsubsync ffsubsync
</label> </label>
</div> </div>
<label id="subsyncSourceLabel" class="subsync-field"> <label id="subsyncReferenceLabel" class="subsync-field">
<span>Source Subtitle (for alass)</span> <span>Reference (correct timing, for alass)</span>
<select id="subsyncSourceSelect"></select> <select id="subsyncReferenceSelect"></select>
</label>
<label id="subsyncTargetLabel" class="subsync-field">
<span>Out-of-sync Subtitle (gets retimed)</span>
<select id="subsyncTargetSelect"></select>
</label> </label>
</div> </div>
<div id="subsyncStatus" class="runtime-options-status"></div> <div id="subsyncStatus" class="runtime-options-status"></div>
+183 -32
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createSubsyncModal } from './subsync.js'; import { createSubsyncModal } from './subsync.js';
import type { SubsyncManualPayload, SubsyncManualRunRequest } from '../../types';
type Listener = () => void; type Listener = () => void;
@@ -52,18 +53,54 @@ function createDeferred<T>() {
return { promise, resolve }; return { promise, resolve };
} }
function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; message: string }>) { function createSelectStub() {
const options: Array<{ value: string; textContent: string }> = [];
const events = createEventTarget();
let innerHTML = '';
let value = '';
return {
options,
disabled: false,
addEventListener: events.addEventListener,
dispatch: events.dispatch,
get innerHTML(): string {
return innerHTML;
},
set innerHTML(next: string) {
innerHTML = next;
if (next === '') {
options.length = 0;
value = '';
}
},
get value(): string {
return value;
},
set value(next: string) {
value = next;
},
appendChild(option: { value: string; textContent: string }) {
options.push(option);
if (!value) value = option.value;
return option;
},
};
}
function createTestHarness(
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<{ ok: boolean; message: string }>,
) {
const overlayClassList = createClassList(); const overlayClassList = createClassList();
const modalClassList = createClassList(); const modalClassList = createClassList();
const statusClassList = createClassList(); const statusClassList = createClassList();
const sourceLabelClassList = createClassList(); const referenceLabelClassList = createClassList();
const targetLabelClassList = createClassList();
const runButtonEvents = createEventTarget(); const runButtonEvents = createEventTarget();
const closeButtonEvents = createEventTarget(); const closeButtonEvents = createEventTarget();
const engineAlassEvents = createEventTarget(); const engineAlassEvents = createEventTarget();
const engineFfsubsyncEvents = createEventTarget(); const engineFfsubsyncEvents = createEventTarget();
const sourceOptions: Array<{ value: string; textContent: string }> = [];
const runButton = { const runButton = {
disabled: false, disabled: false,
addEventListener: runButtonEvents.addEventListener, addEventListener: runButtonEvents.addEventListener,
@@ -77,6 +114,7 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
const subsyncEngineAlass = { const subsyncEngineAlass = {
checked: false, checked: false,
disabled: false,
addEventListener: engineAlassEvents.addEventListener, addEventListener: engineAlassEvents.addEventListener,
dispatch: engineAlassEvents.dispatch, dispatch: engineAlassEvents.dispatch,
}; };
@@ -88,18 +126,8 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
dispatch: engineFfsubsyncEvents.dispatch, dispatch: engineFfsubsyncEvents.dispatch,
}; };
const sourceSelect = { const referenceSelect = createSelectStub();
innerHTML: '', const targetSelect = createSelectStub();
value: '',
disabled: false,
appendChild: (option: { value: string; textContent: string }) => {
sourceOptions.push(option);
if (!sourceSelect.value) {
sourceSelect.value = option.value;
}
return option;
},
};
let notifyClosedCalls = 0; let notifyClosedCalls = 0;
let notifyOpenedCalls = 0; let notifyOpenedCalls = 0;
@@ -139,8 +167,10 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
subsyncCloseButton: closeButton, subsyncCloseButton: closeButton,
subsyncEngineAlass, subsyncEngineAlass,
subsyncEngineFfsubsync, subsyncEngineFfsubsync,
subsyncSourceLabel: { classList: sourceLabelClassList }, subsyncReferenceLabel: { classList: referenceLabelClassList },
subsyncSourceSelect: sourceSelect, subsyncReferenceSelect: referenceSelect,
subsyncTargetLabel: { classList: targetLabelClassList },
subsyncTargetSelect: targetSelect,
subsyncRunButton: runButton, subsyncRunButton: runButton,
subsyncStatus: { subsyncStatus: {
textContent: '', textContent: '',
@@ -149,7 +179,7 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
}, },
state: { state: {
subsyncModalOpen: false, subsyncModalOpen: false,
subsyncSourceTracks: [], subsyncSubtitleTracks: [],
subsyncSubmitting: false, subsyncSubmitting: false,
isOverSubtitle: false, isOverSubtitle: false,
}, },
@@ -166,6 +196,9 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
ctx, ctx,
modal, modal,
runButton, runButton,
referenceSelect,
targetSelect,
referenceLabelClassList,
statusClassList, statusClassList,
getNotifyClosedCalls: () => notifyClosedCalls, getNotifyClosedCalls: () => notifyClosedCalls,
getNotifyOpenedCalls: () => notifyOpenedCalls, getNotifyOpenedCalls: () => notifyOpenedCalls,
@@ -187,16 +220,28 @@ async function flushMicrotasks(): Promise<void> {
await Promise.resolve(); await Promise.resolve();
} }
const BASE_PAYLOAD: SubsyncManualPayload = {
subtitleTracks: [
{ id: 1, label: 'External #1 - jpn (active)' },
{ id: 2, label: 'External #2 - eng' },
],
defaultReferenceTrackId: 2,
defaultTargetTrackId: 1,
videoReferenceAvailable: true,
ffsubsyncAvailable: true,
};
function payloadWith(overrides: Partial<SubsyncManualPayload>): SubsyncManualPayload {
return { ...BASE_PAYLOAD, ...overrides };
}
test('manual subsync failure closes during run, then reopens modal with error', async () => { test('manual subsync failure closes during run, then reopens modal with error', async () => {
const deferred = createDeferred<{ ok: boolean; message: string }>(); const deferred = createDeferred<{ ok: boolean; message: string }>();
const harness = createTestHarness(async () => deferred.promise); const harness = createTestHarness(async () => deferred.promise);
try { try {
harness.modal.wireDomEvents(); harness.modal.wireDomEvents();
harness.modal.openSubsyncModal({ harness.modal.openSubsyncModal(BASE_PAYLOAD);
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
ffsubsyncAvailable: true,
});
harness.runButton.dispatch('click'); harness.runButton.dispatch('click');
await Promise.resolve(); await Promise.resolve();
@@ -219,7 +264,8 @@ test('manual subsync failure closes during run, then reopens modal with error',
assert.equal(harness.statusClassList.contains('error'), true); assert.equal(harness.statusClassList.contains('error'), true);
assert.equal(harness.ctx.dom.subsyncRunButton.disabled, false); assert.equal(harness.ctx.dom.subsyncRunButton.disabled, false);
assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true); assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true);
assert.equal(harness.ctx.dom.subsyncSourceSelect.value, '2'); assert.equal(harness.referenceSelect.value, '2');
assert.equal(harness.targetSelect.value, '1');
assert.equal(harness.getNotifyClosedCalls(), 1); assert.equal(harness.getNotifyClosedCalls(), 1);
assert.equal(harness.getNotifyOpenedCalls(), 1); assert.equal(harness.getNotifyOpenedCalls(), 1);
} finally { } finally {
@@ -231,15 +277,17 @@ test('subsync modal disables ffsubsync when payload marks it unavailable', () =>
const harness = createTestHarness(async () => ({ ok: true, message: 'ok' })); const harness = createTestHarness(async () => ({ ok: true, message: 'ok' }));
try { try {
harness.modal.openSubsyncModal({ harness.modal.openSubsyncModal(
sourceTracks: [{ id: 2, label: 'External #2 - eng' }], payloadWith({ ffsubsyncAvailable: false, videoReferenceAvailable: false }),
ffsubsyncAvailable: false, );
});
assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true); assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true);
assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.checked, false); assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.checked, false);
assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.disabled, true); assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.disabled, true);
assert.equal(harness.ctx.dom.subsyncStatus.textContent, 'Choose alass source, then run.'); assert.equal(
harness.ctx.dom.subsyncStatus.textContent,
'Choose the alass reference and out-of-sync subtitle, then run.',
);
} finally { } finally {
harness.restoreGlobals(); harness.restoreGlobals();
} }
@@ -253,10 +301,14 @@ test('subsync modal ignores enter submission when no sync engine is available',
}); });
try { try {
harness.modal.openSubsyncModal({ harness.modal.openSubsyncModal(
sourceTracks: [], payloadWith({
subtitleTracks: [{ id: 1, label: 'External #1 - jpn (active)' }],
defaultReferenceTrackId: null,
videoReferenceAvailable: false,
ffsubsyncAvailable: false, ffsubsyncAvailable: false,
}); }),
);
harness.modal.handleSubsyncKeydown({ harness.modal.handleSubsyncKeydown({
key: 'Enter', key: 'Enter',
@@ -270,3 +322,102 @@ test('subsync modal ignores enter submission when no sync engine is available',
harness.restoreGlobals(); harness.restoreGlobals();
} }
}); });
test('subsync modal defaults reference to the secondary track and target to the primary', async () => {
let request: SubsyncManualRunRequest | null = null;
const harness = createTestHarness(async (nextRequest) => {
request = nextRequest;
return { ok: true, message: 'ok' };
});
try {
harness.modal.wireDomEvents();
harness.modal.openSubsyncModal(BASE_PAYLOAD);
assert.equal(harness.referenceSelect.value, '2');
assert.equal(harness.targetSelect.value, '1');
harness.runButton.dispatch('click');
await flushMicrotasks();
assert.deepEqual(request, {
engine: 'alass',
targetTrackId: 1,
referenceMode: 'track',
referenceTrackId: 2,
});
} finally {
harness.restoreGlobals();
}
});
test('subsync modal offers the video file as an alass reference and excludes the target track', () => {
const harness = createTestHarness(async () => ({ ok: true, message: 'ok' }));
try {
harness.modal.wireDomEvents();
harness.modal.openSubsyncModal(BASE_PAYLOAD);
assert.deepEqual(
harness.referenceSelect.options.map((option) => option.value),
['2', 'video'],
);
harness.targetSelect.value = '2';
harness.targetSelect.dispatch('change');
assert.deepEqual(
harness.referenceSelect.options.map((option) => option.value),
['1', 'video'],
);
assert.equal(harness.referenceSelect.value, '1');
} finally {
harness.restoreGlobals();
}
});
test('subsync modal sends the video reference mode when the video file is selected', async () => {
let request: SubsyncManualRunRequest | null = null;
const harness = createTestHarness(async (nextRequest) => {
request = nextRequest;
return { ok: true, message: 'ok' };
});
try {
harness.modal.wireDomEvents();
harness.modal.openSubsyncModal(BASE_PAYLOAD);
harness.referenceSelect.value = 'video';
harness.runButton.dispatch('click');
await flushMicrotasks();
assert.deepEqual(request, {
engine: 'alass',
targetTrackId: 1,
referenceMode: 'video',
referenceTrackId: null,
});
} finally {
harness.restoreGlobals();
}
});
test('subsync modal hides the reference picker for ffsubsync but keeps the target picker', () => {
const harness = createTestHarness(async () => ({ ok: true, message: 'ok' }));
try {
harness.modal.wireDomEvents();
harness.modal.openSubsyncModal(BASE_PAYLOAD);
assert.equal(harness.referenceLabelClassList.contains('hidden'), false);
harness.ctx.dom.subsyncEngineAlass.checked = false;
harness.ctx.dom.subsyncEngineFfsubsync.checked = true;
harness.ctx.dom.subsyncEngineFfsubsync.dispatch('change');
assert.equal(harness.referenceLabelClassList.contains('hidden'), true);
assert.equal(harness.targetSelect.value, '1');
} finally {
harness.restoreGlobals();
}
});
+151 -67
View File
@@ -1,6 +1,14 @@
import type { SubsyncManualPayload } from '../../types'; import type { SubsyncManualPayload, SubsyncManualRunRequest } from '../../types';
import type { ModalStateReader, RendererContext } from '../context'; import type { ModalStateReader, RendererContext } from '../context';
const VIDEO_REFERENCE_VALUE = 'video';
interface SubsyncSelection {
engine: 'alass' | 'ffsubsync';
referenceValue: string;
targetTrackId: number | null;
}
export function createSubsyncModal( export function createSubsyncModal(
ctx: RendererContext, ctx: RendererContext,
options: { options: {
@@ -8,27 +16,103 @@ export function createSubsyncModal(
syncSettingsModalSubtitleSuppression: () => void; syncSettingsModalSubtitleSuppression: () => void;
}, },
) { ) {
let ffsubsyncAvailable = true; let currentPayload: SubsyncManualPayload | null = null;
function setSubsyncStatus(message: string, isError = false): void { function setSubsyncStatus(message: string, isError = false): void {
ctx.dom.subsyncStatus.textContent = message; ctx.dom.subsyncStatus.textContent = message;
ctx.dom.subsyncStatus.classList.toggle('error', isError); ctx.dom.subsyncStatus.classList.toggle('error', isError);
} }
function updateSubsyncSourceVisibility(): void { function hasAlassReference(): boolean {
const useAlass = ctx.dom.subsyncEngineAlass.checked; if (!currentPayload) return false;
ctx.dom.subsyncSourceLabel.classList.toggle('hidden', !useAlass); return currentPayload.videoReferenceAvailable || currentPayload.subtitleTracks.length > 1;
} }
function renderSubsyncSourceTracks(): void { function updateSubsyncFieldVisibility(): void {
ctx.dom.subsyncSourceSelect.innerHTML = ''; const useAlass = ctx.dom.subsyncEngineAlass.checked;
for (const track of ctx.state.subsyncSourceTracks) { ctx.dom.subsyncReferenceLabel.classList.toggle('hidden', !useAlass);
const option = document.createElement('option'); ctx.dom.subsyncTargetLabel.classList.toggle(
option.value = String(track.id); 'hidden',
option.textContent = track.label; ctx.state.subsyncSubtitleTracks.length === 0,
ctx.dom.subsyncSourceSelect.appendChild(option); );
} }
ctx.dom.subsyncSourceSelect.disabled = ctx.state.subsyncSourceTracks.length === 0;
function appendOption(select: HTMLSelectElement, value: string, label: string): void {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
select.appendChild(option);
}
function getSelectedTargetTrackId(): number | null {
const raw = Number.parseInt(ctx.dom.subsyncTargetSelect.value, 10);
return Number.isFinite(raw) ? raw : null;
}
function renderTargetTracks(preferredTrackId: number | null): void {
const select = ctx.dom.subsyncTargetSelect;
select.innerHTML = '';
select.value = '';
for (const track of ctx.state.subsyncSubtitleTracks) {
appendOption(select, String(track.id), track.label);
}
select.disabled = ctx.state.subsyncSubtitleTracks.length === 0;
const preferred = ctx.state.subsyncSubtitleTracks.find(
(track) => track.id === preferredTrackId,
);
const fallback = ctx.state.subsyncSubtitleTracks[0];
const selected = preferred ?? fallback;
if (selected) {
select.value = String(selected.id);
}
}
function renderReferenceTracks(preferredValue: string | null): void {
const select = ctx.dom.subsyncReferenceSelect;
const targetTrackId = getSelectedTargetTrackId();
const values: string[] = [];
select.innerHTML = '';
select.value = '';
for (const track of ctx.state.subsyncSubtitleTracks) {
if (track.id === targetTrackId) continue;
appendOption(select, String(track.id), track.label);
values.push(String(track.id));
}
if (currentPayload?.videoReferenceAvailable) {
appendOption(select, VIDEO_REFERENCE_VALUE, 'Video file (audio reference)');
values.push(VIDEO_REFERENCE_VALUE);
}
select.disabled = values.length === 0;
const preferred = preferredValue && values.includes(preferredValue) ? preferredValue : null;
const defaultTrackValue =
currentPayload?.defaultReferenceTrackId !== null &&
currentPayload?.defaultReferenceTrackId !== undefined
? String(currentPayload.defaultReferenceTrackId)
: null;
const fallback =
defaultTrackValue && values.includes(defaultTrackValue) ? defaultTrackValue : values[0];
const selected = preferred ?? fallback;
if (selected) {
select.value = selected;
}
}
function describeSubsyncState(): string {
if (!currentPayload) return '';
const alassReady = hasAlassReference();
if (alassReady && currentPayload.ffsubsyncAvailable) {
return 'Choose engine, reference and out-of-sync subtitle, then run.';
}
if (alassReady) {
return 'Choose the alass reference and out-of-sync subtitle, then run.';
}
if (currentPayload.ffsubsyncAvailable) {
return 'No reference available for alass. Use ffsubsync.';
}
return 'No sync engine available for current media.';
} }
function closeSubsyncModal(): void { function closeSubsyncModal(): void {
@@ -46,30 +130,27 @@ export function createSubsyncModal(
} }
} }
function openSubsyncModal(payload: SubsyncManualPayload): void { function openSubsyncModal(payload: SubsyncManualPayload, selection?: SubsyncSelection): void {
ctx.state.subsyncSubmitting = false; ctx.state.subsyncSubmitting = false;
ctx.state.subsyncSourceTracks = payload.sourceTracks; ctx.state.subsyncSubtitleTracks = payload.subtitleTracks;
ffsubsyncAvailable = payload.ffsubsyncAvailable; currentPayload = payload;
const hasSources = ctx.state.subsyncSourceTracks.length > 0; const alassReady = hasAlassReference();
ctx.dom.subsyncEngineAlass.checked = hasSources; const useAlass = selection ? selection.engine === 'alass' && alassReady : alassReady;
ctx.dom.subsyncEngineFfsubsync.checked = !hasSources && ffsubsyncAvailable; ctx.dom.subsyncEngineAlass.checked = useAlass;
ctx.dom.subsyncEngineFfsubsync.disabled = !ffsubsyncAvailable; ctx.dom.subsyncEngineFfsubsync.checked = !useAlass && payload.ffsubsyncAvailable;
ctx.dom.subsyncRunButton.disabled = !hasSources && !ffsubsyncAvailable; ctx.dom.subsyncEngineAlass.disabled = !alassReady;
ctx.dom.subsyncEngineFfsubsync.disabled = !payload.ffsubsyncAvailable;
ctx.dom.subsyncRunButton.disabled = !alassReady && !payload.ffsubsyncAvailable;
renderSubsyncSourceTracks(); renderTargetTracks(
updateSubsyncSourceVisibility(); selection
? (selection.targetTrackId ?? payload.defaultTargetTrackId)
setSubsyncStatus( : payload.defaultTargetTrackId,
!ffsubsyncAvailable && hasSources
? 'Choose alass source, then run.'
: !ffsubsyncAvailable
? 'No source subtitles available for alass.'
: hasSources
? 'Choose engine and source, then run.'
: 'No source subtitles available for alass. Use ffsubsync.',
false,
); );
renderReferenceTracks(selection?.referenceValue ?? null);
updateSubsyncFieldVisibility();
setSubsyncStatus(describeSubsyncState(), false);
ctx.state.subsyncModalOpen = true; ctx.state.subsyncModalOpen = true;
options.syncSettingsModalSubtitleSuppression(); options.syncSettingsModalSubtitleSuppression();
@@ -80,25 +161,11 @@ export function createSubsyncModal(
} }
function reopenSubsyncModalWithError( function reopenSubsyncModalWithError(
sourceTracks: SubsyncManualPayload['sourceTracks'], payload: SubsyncManualPayload,
engine: 'alass' | 'ffsubsync', selection: SubsyncSelection,
sourceTrackId: number | null,
message: string, message: string,
): void { ): void {
openSubsyncModal({ sourceTracks, ffsubsyncAvailable }); openSubsyncModal(payload, selection);
if (engine === 'alass' && sourceTracks.length > 0) {
ctx.dom.subsyncEngineAlass.checked = true;
ctx.dom.subsyncEngineFfsubsync.checked = false;
if (Number.isFinite(sourceTrackId)) {
ctx.dom.subsyncSourceSelect.value = String(sourceTrackId);
}
} else if (ffsubsyncAvailable) {
ctx.dom.subsyncEngineAlass.checked = false;
ctx.dom.subsyncEngineFfsubsync.checked = true;
}
updateSubsyncSourceVisibility();
setSubsyncStatus(message, true); setSubsyncStatus(message, true);
window.electronAPI.notifyOverlayModalOpened('subsync'); window.electronAPI.notifyOverlayModalOpened('subsync');
} }
@@ -106,6 +173,7 @@ export function createSubsyncModal(
async function runSubsyncManualFromModal(): Promise<void> { async function runSubsyncManualFromModal(): Promise<void> {
if (ctx.state.subsyncSubmitting) return; if (ctx.state.subsyncSubmitting) return;
if (ctx.dom.subsyncRunButton.disabled) return; if (ctx.dom.subsyncRunButton.disabled) return;
if (!currentPayload) return;
const useAlass = ctx.dom.subsyncEngineAlass.checked; const useAlass = ctx.dom.subsyncEngineAlass.checked;
const useFfsubsync = ctx.dom.subsyncEngineFfsubsync.checked; const useFfsubsync = ctx.dom.subsyncEngineFfsubsync.checked;
@@ -115,33 +183,46 @@ export function createSubsyncModal(
} }
const engine = useAlass ? 'alass' : 'ffsubsync'; const engine = useAlass ? 'alass' : 'ffsubsync';
const sourceTrackId = const referenceValue = ctx.dom.subsyncReferenceSelect.value;
engine === 'alass' && ctx.dom.subsyncSourceSelect.value const targetTrackId = getSelectedTargetTrackId();
? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10)
: null;
if (engine === 'alass' && !Number.isFinite(sourceTrackId)) { if (targetTrackId === null) {
setSubsyncStatus('Select a source subtitle track for alass.', true); setSubsyncStatus('Select the out-of-sync subtitle track to retime.', true);
return;
}
if (engine === 'alass' && !referenceValue) {
setSubsyncStatus('Select a reference for alass.', true);
return; return;
} }
const sourceTracksSnapshot = ctx.state.subsyncSourceTracks.map((track) => ({ ...track })); const useVideoReference = referenceValue === VIDEO_REFERENCE_VALUE;
const request: SubsyncManualRunRequest = {
engine,
targetTrackId,
};
if (engine === 'alass') {
request.referenceMode = useVideoReference ? 'video' : 'track';
request.referenceTrackId = useVideoReference ? null : Number.parseInt(referenceValue, 10);
}
const payloadSnapshot: SubsyncManualPayload = {
...currentPayload,
subtitleTracks: currentPayload.subtitleTracks.map((track) => ({ ...track })),
};
const selection: SubsyncSelection = { engine, referenceValue, targetTrackId };
ctx.state.subsyncSubmitting = true; ctx.state.subsyncSubmitting = true;
ctx.dom.subsyncRunButton.disabled = true; ctx.dom.subsyncRunButton.disabled = true;
closeSubsyncModal(); closeSubsyncModal();
try { try {
const result = await window.electronAPI.runSubsyncManual({ const result = await window.electronAPI.runSubsyncManual(request);
engine,
sourceTrackId,
});
if (result.ok) return; if (result.ok) return;
reopenSubsyncModalWithError(sourceTracksSnapshot, engine, sourceTrackId, result.message); reopenSubsyncModalWithError(payloadSnapshot, selection, result.message);
} catch (error) { } catch (error) {
reopenSubsyncModalWithError( reopenSubsyncModalWithError(
sourceTracksSnapshot, payloadSnapshot,
engine, selection,
sourceTrackId,
`Subsync failed: ${(error as Error).message}`, `Subsync failed: ${(error as Error).message}`,
); );
} finally { } finally {
@@ -171,10 +252,13 @@ export function createSubsyncModal(
closeSubsyncModal(); closeSubsyncModal();
}); });
ctx.dom.subsyncEngineAlass.addEventListener('change', () => { ctx.dom.subsyncEngineAlass.addEventListener('change', () => {
updateSubsyncSourceVisibility(); updateSubsyncFieldVisibility();
}); });
ctx.dom.subsyncEngineFfsubsync.addEventListener('change', () => { ctx.dom.subsyncEngineFfsubsync.addEventListener('change', () => {
updateSubsyncSourceVisibility(); updateSubsyncFieldVisibility();
});
ctx.dom.subsyncTargetSelect.addEventListener('change', () => {
renderReferenceTracks(ctx.dom.subsyncReferenceSelect.value || null);
}); });
ctx.dom.subsyncRunButton.addEventListener('click', () => { ctx.dom.subsyncRunButton.addEventListener('click', () => {
void runSubsyncManualFromModal(); void runSubsyncManualFromModal();
+3 -3
View File
@@ -18,7 +18,7 @@ import type {
SubtitlePosition, SubtitlePosition,
SubtitleSidebarSnapshotConfig, SubtitleSidebarSnapshotConfig,
SubtitleCue, SubtitleCue,
SubsyncSourceTrack, SubsyncSubtitleTrack,
YoutubePickerOpenPayload, YoutubePickerOpenPayload,
} from '../types'; } from '../types';
@@ -85,7 +85,7 @@ export type RendererState = {
characterDictionaryStatus: string; characterDictionaryStatus: string;
subsyncModalOpen: boolean; subsyncModalOpen: boolean;
subsyncSourceTracks: SubsyncSourceTrack[]; subsyncSubtitleTracks: SubsyncSubtitleTrack[];
subsyncSubmitting: boolean; subsyncSubmitting: boolean;
controllerSelectModalOpen: boolean; controllerSelectModalOpen: boolean;
@@ -213,7 +213,7 @@ export function createRendererState(): RendererState {
characterDictionaryStatus: '', characterDictionaryStatus: '',
subsyncModalOpen: false, subsyncModalOpen: false,
subsyncSourceTracks: [], subsyncSubtitleTracks: [],
subsyncSubmitting: false, subsyncSubmitting: false,
controllerSelectModalOpen: false, controllerSelectModalOpen: false,
+18
View File
@@ -1004,6 +1004,24 @@ test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks i
); );
}); });
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.
assert.equal(normalizeSubtitle('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
assert.equal(normalizeSubtitle(' 余白 ', false), ' 余白 ');
});
test('prepareSecondarySubtitleLines drops ASS vector drawing runs', () => {
assert.deepEqual(
prepareSecondarySubtitleLines(
'{\\an5\\pos(730,1042)\\p1\\blur1}m 20 0 b 10 0 0 10 0 20 b 0 31 10 40 20 40 {\\p0}',
),
[],
);
assert.deepEqual(prepareSecondarySubtitleLines('{\\p1}m 0 0 l 10 10{\\p0}本文'), ['本文']);
assert.deepEqual(prepareSecondarySubtitleLines('{\\pos(960,1068)\\bord3}位置指定'), ['位置指定']);
});
test('shouldRenderTokenizedSubtitle enables token rendering when tokens exist', () => { test('shouldRenderTokenizedSubtitle enables token rendering when tokens exist', () => {
assert.equal(shouldRenderTokenizedSubtitle(5), true); assert.equal(shouldRenderTokenizedSubtitle(5), true);
assert.equal(shouldRenderTokenizedSubtitle(0), false); assert.equal(shouldRenderTokenizedSubtitle(0), false);
+8 -11
View File
@@ -5,6 +5,7 @@ import type {
SubtitleData, SubtitleData,
SubtitleRendererStyleConfig, SubtitleRendererStyleConfig,
} from '../types'; } from '../types';
import { assToPlainText, normalizePlainSubtitleText } from '../core/services/ass-text.js';
import type { RendererContext } from './context'; import type { RendererContext } from './context';
import { PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS } from './yomitan-popup.js'; import { PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS } from './yomitan-popup.js';
@@ -42,17 +43,10 @@ function isWhitespaceOnly(value: string): boolean {
return value.trim().length === 0; return value.trim().length === 0;
} }
// Text reaching the overlay has already been decoded from ASS -- by mpv for live lines,
// by the cue parser for prefetched ones -- so this only settles line breaks.
export function normalizeSubtitle(text: string, trim = true, collapseLineBreaks = false): string { export function normalizeSubtitle(text: string, trim = true, collapseLineBreaks = false): string {
if (!text) return ''; return normalizePlainSubtitleText(text, { trim, collapseLineBreaks });
let normalized = text.replace(/\\N/g, '\n').replace(/\\n/g, '\n');
normalized = normalized.replace(/\{[^}]*\}/g, '');
if (collapseLineBreaks) {
normalized = normalized.replace(/\n/g, ' ');
normalized = normalized.replace(/\s+/g, ' ');
}
return trim ? normalized.trim() : normalized;
} }
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 HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
@@ -672,7 +666,10 @@ function isKaraokeLikeLineSet(lines: string[]): boolean {
} }
export function prepareSecondarySubtitleLines(text: string): string[] { export function prepareSecondarySubtitleLines(text: string): string[] {
const normalized = normalizeSubtitle(text, true, false); // The one display-side ASS decode: secondary text also reaches the overlay from
// websocket clients that forward their source line untouched, so unlike the primary
// path it cannot assume mpv already decoded it.
const normalized = assToPlainText(text).trim();
if (!normalized) return []; if (!normalized) return [];

Some files were not shown because too many files have changed in this diff Show More