Compare commits

...

4 Commits

51 changed files with 1963 additions and 310 deletions
+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.
@@ -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.
+2 -2
View File
@@ -1196,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
+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.
+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
+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
+5 -3
View File
@@ -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",
-32
View File
@@ -1,32 +0,0 @@
## Highlights
### Added
- Kiku/Lapis Word Card Type Setting
- A new setting (Mining/Anki > Kiku/Lapis Features > "Word Card Type") lets you choose which card-type flag gets marked on Kiku/Lapis word cards, including a click-card option that SubMiner couldn't set before.
- Handy if you only want click cards flagged instead of the default word-and-sentence marking.
- Choosing a card type now clears any other flags automatically, so a note can't end up marked as two types at once.
### Fixed
- Yomitan Popup on macOS
- Fixed the popup going unresponsive after mining a card — clicks outside it no longer leak through to mpv, and the overlay no longer flickers hidden and shown.
- Scrolling over the popup now scrolls its definitions instead of seeking the video.
- YouTube Playlist Links
- Opening a video from a playlist URL (like a Watch Later link with `list=`/`index=`) no longer times out while loading subtitles, metadata, or playback info.
## What's Changed
- feat(anki): add configurable word card type for Kiku/Lapis by @ksyasuda in #175
- fix(overlay): keep Yomitan popup interactive on macOS/Windows by @ksyasuda in #177
- fix(youtube): prevent playlist URLs from stalling yt-dlp probes by @ksyasuda in #180
## 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`.
+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');
} }
+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');
+201 -40
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 subtitleTracks = context.subtitleTracks
.filter((track) => typeof track.id === 'number')
.map((track) => ({
id: track.id as number,
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 = { 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), ffsubsyncAvailable: !isRemoteMediaPath(context.videoPath),
sourceTracks: context.sourceTracks
.filter((track) => typeof track.id === 'number')
.map((track) => ({
id: track.id as number,
label: formatTrackLabel(track),
})),
}; };
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 {
+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,138 @@ 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('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 +161,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 +184,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 +211,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 +229,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 +290,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 +319,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 +344,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 +360,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 +385,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 +488,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);
}); });
@@ -5,8 +5,17 @@ export interface SubtitleProcessingControllerDeps {
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,7 +23,6 @@ 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;
} }
export function normalizeSubtitleCacheKey(text: string): string { export function normalizeSubtitleCacheKey(text: string): string {
@@ -24,9 +32,15 @@ export function normalizeSubtitleCacheKey(text: string): string {
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;
@@ -70,9 +84,12 @@ export function createSubtitleProcessingController(
const startedAtMs = now(); const startedAtMs = now();
if (!text.trim()) { if (!text.trim()) {
deps.emitSubtitle({ text, tokens: null }); if (lastPlainEmittedText !== text) {
deps.emitSubtitle({ text, tokens: null });
}
lastEmittedText = text; lastEmittedText = text;
lastEmittedGeneration = generation; lastEmittedGeneration = generation;
lastPlainEmittedText = null;
break; break;
} }
@@ -82,11 +99,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 +138,16 @@ export function createSubtitleProcessingController(
continue; continue;
} }
deps.emitSubtitle(output); // 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);
}
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 +174,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 +215,11 @@ 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)); return tokenizationCache.has(normalizeSubtitleCacheKey(text));
}, },
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;
}; };
+17 -3
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);
subtitleWsService.broadcast(timedPayload, frequencyOptions); if (!isAnnotationUpgrade) {
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);
@@ -11,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}`);
@@ -25,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',
]); ]);
}); });
@@ -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();
@@ -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,
+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)
}`,
);
} }
}; };
} }
+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>
+184 -33
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({
ffsubsyncAvailable: false, subtitleTracks: [{ id: 1, label: 'External #1 - jpn (active)' }],
}); defaultReferenceTrackId: null,
videoReferenceAvailable: 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); );
}
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);
} }
ctx.dom.subsyncSourceSelect.disabled = ctx.state.subsyncSourceTracks.length === 0; 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,
+8 -4
View File
@@ -90,8 +90,10 @@ export type RendererDom = {
subsyncCloseButton: HTMLButtonElement; subsyncCloseButton: HTMLButtonElement;
subsyncEngineAlass: HTMLInputElement; subsyncEngineAlass: HTMLInputElement;
subsyncEngineFfsubsync: HTMLInputElement; subsyncEngineFfsubsync: HTMLInputElement;
subsyncSourceLabel: HTMLLabelElement; subsyncReferenceLabel: HTMLLabelElement;
subsyncSourceSelect: HTMLSelectElement; subsyncReferenceSelect: HTMLSelectElement;
subsyncTargetLabel: HTMLLabelElement;
subsyncTargetSelect: HTMLSelectElement;
subsyncRunButton: HTMLButtonElement; subsyncRunButton: HTMLButtonElement;
subsyncStatus: HTMLDivElement; subsyncStatus: HTMLDivElement;
@@ -255,8 +257,10 @@ export function resolveRendererDom(): RendererDom {
subsyncCloseButton: getRequiredElement<HTMLButtonElement>('subsyncClose'), subsyncCloseButton: getRequiredElement<HTMLButtonElement>('subsyncClose'),
subsyncEngineAlass: getRequiredElement<HTMLInputElement>('subsyncEngineAlass'), subsyncEngineAlass: getRequiredElement<HTMLInputElement>('subsyncEngineAlass'),
subsyncEngineFfsubsync: getRequiredElement<HTMLInputElement>('subsyncEngineFfsubsync'), subsyncEngineFfsubsync: getRequiredElement<HTMLInputElement>('subsyncEngineFfsubsync'),
subsyncSourceLabel: getRequiredElement<HTMLLabelElement>('subsyncSourceLabel'), subsyncReferenceLabel: getRequiredElement<HTMLLabelElement>('subsyncReferenceLabel'),
subsyncSourceSelect: getRequiredElement<HTMLSelectElement>('subsyncSourceSelect'), subsyncReferenceSelect: getRequiredElement<HTMLSelectElement>('subsyncReferenceSelect'),
subsyncTargetLabel: getRequiredElement<HTMLLabelElement>('subsyncTargetLabel'),
subsyncTargetSelect: getRequiredElement<HTMLSelectElement>('subsyncTargetSelect'),
subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'), subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'),
subsyncStatus: getRequiredElement<HTMLDivElement>('subsyncStatus'), subsyncStatus: getRequiredElement<HTMLDivElement>('subsyncStatus'),
+16 -3
View File
@@ -295,14 +295,27 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null { export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null {
if (!isObject(value)) return null; if (!isObject(value)) return null;
const { engine, sourceTrackId } = value; const { engine, referenceMode, referenceTrackId, targetTrackId } = value;
if (engine !== 'alass' && engine !== 'ffsubsync') return null; if (engine !== 'alass' && engine !== 'ffsubsync') return null;
if (sourceTrackId !== undefined && sourceTrackId !== null && !isInteger(sourceTrackId)) { if (referenceMode !== undefined && referenceMode !== 'track' && referenceMode !== 'video') {
return null; return null;
} }
const parseOptionalTrackId = (raw: unknown): number | null | undefined | false => {
if (raw === undefined) return undefined;
if (raw === null) return null;
return isInteger(raw) ? raw : false;
};
const parsedReferenceTrackId = parseOptionalTrackId(referenceTrackId);
const parsedTargetTrackId = parseOptionalTrackId(targetTrackId);
if (parsedReferenceTrackId === false || parsedTargetTrackId === false) return null;
return { return {
engine, engine,
sourceTrackId: sourceTrackId === undefined ? undefined : (sourceTrackId as number | null), referenceMode,
referenceTrackId: parsedReferenceTrackId,
targetTrackId: parsedTargetTrackId,
}; };
} }
+2 -1
View File
@@ -33,7 +33,8 @@ export interface SubsyncContext {
videoPath: string; videoPath: string;
primaryTrack: MpvTrack; primaryTrack: MpvTrack;
secondaryTrack: MpvTrack | null; secondaryTrack: MpvTrack | null;
sourceTracks: MpvTrack[]; /** Every usable subtitle track, including the primary one. */
subtitleTracks: MpvTrack[];
audioStreamIndex: number | null; audioStreamIndex: number | null;
} }
+12 -3
View File
@@ -78,19 +78,28 @@ export interface MpvClient {
send(command: { command: unknown[]; request_id?: number }): boolean; send(command: { command: unknown[]; request_id?: number }): boolean;
} }
export interface SubsyncSourceTrack { export interface SubsyncSubtitleTrack {
id: number; id: number;
label: string; label: string;
} }
export interface SubsyncManualPayload { export interface SubsyncManualPayload {
sourceTracks: SubsyncSourceTrack[]; subtitleTracks: SubsyncSubtitleTrack[];
defaultReferenceTrackId: number | null;
defaultTargetTrackId: number | null;
videoReferenceAvailable: boolean;
ffsubsyncAvailable: boolean; ffsubsyncAvailable: boolean;
} }
export type SubsyncReferenceMode = 'track' | 'video';
export interface SubsyncManualRunRequest { export interface SubsyncManualRunRequest {
engine: 'alass' | 'ffsubsync'; engine: 'alass' | 'ffsubsync';
sourceTrackId?: number | null; /** alass reference source: another subtitle track, or the loaded media file itself. */
referenceMode?: SubsyncReferenceMode;
referenceTrackId?: number | null;
/** Subtitle track to retime. Defaults to the active primary track. */
targetTrackId?: number | null;
} }
export interface SubsyncResult { export interface SubsyncResult {