diff --git a/.agents/skills/subminer-release/SKILL.md b/.agents/skills/subminer-release/SKILL.md new file mode 100644 index 00000000..89f68d71 --- /dev/null +++ b/.agents/skills/subminer-release/SKILL.md @@ -0,0 +1,34 @@ +--- +name: subminer-release +description: Prepare, cut, publish, or repair SubMiner stable and prerelease releases. Use for hands-on release work; do not use for general release questions. +--- + +# SubMiner release + +Carry out the requested release phase using the repository's current release process. + +## Source of truth + +Read `docs/RELEASING.md` completely before changing files or release state. Treat it as canonical. Read `changes/README.md` when the work touches change fragments or generated release notes. + +Do not copy release commands or policy into this skill. If this skill disagrees with the release guide, follow the guide and reconcile the skill before handoff. + +## Workflow + +1. Identify whether the request is for a stable release, prerelease, release preparation, publication, or repair. +2. Inspect the current branch, worktree status, package version, pending change fragments, relevant tags, and latest CI state before making changes. +3. Follow the matching procedure in `docs/RELEASING.md` in order. Review generated changelog and release-note Markdown before it can be committed or published. +4. Run every required gate for the requested release phase. Do not treat a cheaper test lane as a substitute for the documented release gate. +5. Before a stable tag, confirm the package and tag versions match and no pending `changes/*.md` fragments remain. Preserve fragments for prereleases as documented. +6. Report the resulting version, completed checks, local commit and tag state, remote publication state, skipped platform checks, and any remaining manual work. + +## Authorization boundaries + +- A request to prepare a release stops before commit, tag, push, or remote publication unless the user also authorizes those actions. +- A clear request to cut or publish a release includes the documented commit, tag, and push steps. Ask before the first remote mutation when the wording is ambiguous. +- Do not edit an existing GitHub release, publish to the AUR, change secrets, or alter signing configuration unless the user explicitly requests that operation. +- Do not switch branches without consent. + +## Stop conditions + +Stop and report the blocker when required CI or a release gate fails, authentication is missing, versions disagree, required artifacts are absent, or the worktree contains unexpected changes that overlap the release. Do not tag or publish a partially verified release. diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 9f5a4976..e95f137d 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -32,9 +32,11 @@ jobs: - name: Guard stable docs tag shape id: tag_guard if: github.ref_type == 'tag' + env: + TAG_NAME: ${{ github.ref_name }} run: | - if [[ ! "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::notice::Skipping non-stable docs tag ${{ github.ref_name }}" + if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::notice::Skipping non-stable docs tag $TAG_NAME" echo "stable_tag=false" >> "$GITHUB_OUTPUT" exit 0 fi diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 7888110c..759456e4 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -274,7 +274,8 @@ jobs: config.example.jsonc \ plugin/subminer \ plugin/subminer.conf \ - assets/themes/subminer.rasi + assets/themes/subminer.rasi \ + assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer - name: Generate checksums run: | @@ -296,15 +297,22 @@ jobs: run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" - name: Verify committed prerelease notes + env: + RELEASE_VERSION: ${{ steps.version.outputs.VERSION }} run: | if [ ! -s release/prerelease-notes.md ]; then echo "::error::release/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version ' locally and commit the file before tagging." exit 1 fi + if ! bun run changelog:check-prerelease-notes --version "$RELEASE_VERSION"; then + echo "::error::release/prerelease-notes.md was not generated for $RELEASE_VERSION. Rerun 'bun run changelog:prerelease-notes --version $RELEASE_VERSION' locally, commit, and retag." + exit 1 + fi - name: Publish Prerelease env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ steps.version.outputs.VERSION }} run: | set -euo pipefail @@ -326,27 +334,27 @@ jobs: exit 1 fi - if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then - gh release edit "${{ steps.version.outputs.VERSION }}" \ + if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then + gh release edit "$RELEASE_VERSION" \ --draft \ --prerelease \ - --title "${{ steps.version.outputs.VERSION }}" \ + --title "$RELEASE_VERSION" \ --notes-file release/prerelease-notes.md else - gh release create "${{ steps.version.outputs.VERSION }}" \ + gh release create "$RELEASE_VERSION" \ --draft \ --latest=false \ --prerelease \ - --title "${{ steps.version.outputs.VERSION }}" \ + --title "$RELEASE_VERSION" \ --notes-file release/prerelease-notes.md fi for asset in "${artifacts[@]}"; do - gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber + gh release upload "$RELEASE_VERSION" "$asset" --clobber done - gh release edit "${{ steps.version.outputs.VERSION }}" \ + gh release edit "$RELEASE_VERSION" \ --draft=false \ --prerelease \ - --title "${{ steps.version.outputs.VERSION }}" \ + --title "$RELEASE_VERSION" \ --notes-file release/prerelease-notes.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e3fa97ae..86ba2ce7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -273,7 +273,8 @@ jobs: config.example.jsonc \ plugin/subminer \ plugin/subminer.conf \ - assets/themes/subminer.rasi + assets/themes/subminer.rasi \ + assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer - name: Generate checksums run: | @@ -295,33 +296,40 @@ jobs: run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" - name: Guard against pending changelog fragments + env: + RELEASE_VERSION: ${{ steps.version.outputs.VERSION }} run: | if find changes -maxdepth 1 -name '*.md' -not -name README.md -print -quit | grep -q .; then - echo "::error::Pending changelog fragments detected. Run 'bun run changelog:build --version ${{ steps.version.outputs.VERSION }}' locally and commit the polished CHANGELOG.md before tagging. CI no longer auto-builds the changelog because the polish step requires the local 'claude' CLI." + echo "::error::Pending changelog fragments detected. Run 'bun run changelog:build --version $RELEASE_VERSION' locally and commit the polished CHANGELOG.md before tagging. CI no longer auto-builds the changelog because the polish step requires the local 'claude' CLI." exit 1 fi - name: Verify changelog is ready for tagged release - run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}" + env: + RELEASE_VERSION: ${{ steps.version.outputs.VERSION }} + run: bun run changelog:check --version "$RELEASE_VERSION" - name: Generate release notes from changelog - run: bun run changelog:release-notes --version "${{ steps.version.outputs.VERSION }}" + env: + RELEASE_VERSION: ${{ steps.version.outputs.VERSION }} + run: bun run changelog:release-notes --version "$RELEASE_VERSION" - name: Publish Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ steps.version.outputs.VERSION }} run: | set -euo pipefail - if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then + if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then # Do not pass the prerelease flag here; gh defaults to a normal release. - gh release edit "${{ steps.version.outputs.VERSION }}" \ + gh release edit "$RELEASE_VERSION" \ --draft=false \ - --title "${{ steps.version.outputs.VERSION }}" \ + --title "$RELEASE_VERSION" \ --notes-file release/release-notes.md else - gh release create "${{ steps.version.outputs.VERSION }}" \ - --title "${{ steps.version.outputs.VERSION }}" \ + gh release create "$RELEASE_VERSION" \ + --title "$RELEASE_VERSION" \ --notes-file release/release-notes.md fi @@ -344,7 +352,7 @@ jobs: fi for asset in "${artifacts[@]}"; do - gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber + gh release upload "$RELEASE_VERSION" "$asset" --clobber done aur-publish: @@ -420,9 +428,10 @@ jobs: if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ steps.version.outputs.VERSION }} run: | set -euo pipefail - version="${{ steps.version.outputs.VERSION }}" + version="$RELEASE_VERSION" install -dm755 .tmp/aur-release-assets gh release download "$version" \ --dir .tmp/aur-release-assets \ @@ -432,15 +441,17 @@ jobs: - name: Update AUR packaging metadata if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true' + env: + RELEASE_VERSION: ${{ steps.version.outputs.VERSION }} run: | set -euo pipefail - version_no_v="${{ steps.version.outputs.VERSION }}" + version_no_v="$RELEASE_VERSION" version_no_v="${version_no_v#v}" cp packaging/aur/subminer-bin/PKGBUILD aur-subminer-bin/PKGBUILD cp packaging/aur/subminer-bin/.SRCINFO aur-subminer-bin/.SRCINFO bash scripts/update-aur-package.sh \ --pkg-dir aur-subminer-bin \ - --version "${{ steps.version.outputs.VERSION }}" \ + --version "$RELEASE_VERSION" \ --appimage ".tmp/aur-release-assets/SubMiner-${version_no_v}.AppImage" \ --wrapper ".tmp/aur-release-assets/subminer" \ --assets ".tmp/aur-release-assets/subminer-assets.tar.gz" @@ -450,6 +461,7 @@ jobs: working-directory: aur-subminer-bin env: GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes + RELEASE_VERSION: ${{ steps.version.outputs.VERSION }} run: | set -euo pipefail if git diff --quiet -- PKGBUILD .SRCINFO; then @@ -459,7 +471,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add PKGBUILD .SRCINFO - git commit -m "Update to ${{ steps.version.outputs.VERSION }}" + git commit -m "Update to $RELEASE_VERSION" attempts=3 for attempt in $(seq 1 "$attempts"); do diff --git a/.gitignore b/.gitignore index 2f4cd81a..fbba9131 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ tests/* !.agents/skills/ .agents/skills/* !.agents/skills/subminer-change-verification/ +!.agents/skills/subminer-release/ !.agents/skills/subminer-scrum-master/ .agents/skills/subminer-change-verification/* !.agents/skills/subminer-change-verification/SKILL.md @@ -56,6 +57,8 @@ tests/* .agents/skills/subminer-change-verification/scripts/* !.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh !.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh +.agents/skills/subminer-release/* +!.agents/skills/subminer-release/SKILL.md .agents/skills/subminer-scrum-master/* !.agents/skills/subminer-scrum-master/SKILL.md favicon.png diff --git a/CHANGELOG.md b/CHANGELOG.md index a32fa520..293fb563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,61 @@ # Changelog +## v0.19.5 (2026-08-30) + +### Fixed + +- **Anki Card Update Progress**: The card-update spinner now stays visible until audio and image updates finish, instead of disappearing early. +- **Anki Word-Card Fields**: Word-card enrichment now writes sentence text and audio to the fields configured in AnkiConnect, while the dedicated sentence-card and audio-card actions keep their existing compatible field names. +- **Overlapping Subtitles**: + - Subtitle lines that start while another line is still on screen now appear alongside it, instead of staying hidden until a track switch or seek. + - Subtitles shown at the same time now stack by their authored screen position, with top signs and song lines above bottom dialogue. + - Half-size ASS furigana is no longer shown as if it were a dialogue line. +- **YouTube Auto Captions**: + - Auto-generated captions now follow their intended timing and two-row roll-up layout. + - Long speech is paged instead of covering the video with a wall of text. + - Explicitly timed sound cues like `[音楽]` no longer cover later dialogue. + +## v0.19.4 (2026-08-25) + +### Added +- **Library Merge & Move**: Duplicate library cards for the same show can now be combined. Select cards in the library grid and use "Merge Selected" to pick which entry to keep and move every episode onto it, preserving sessions, mined cards, and watch time. Episodes can also be reassigned individually via the "→" button, useful when a file lands under a stray title; manual assignments survive later filename parsing, Jellyfin refreshes, and season repair. Exact AniList title matches with compatible seasons now merge automatically, while fuzzy matches surface as dismissible "Possible duplicate" reviews instead of merging silently. +- **Duplicate Line Cleanup Tool**: The Vocabulary tab's new "Duplicates" button scans a chosen time window (7 days through all time) for old karaoke/typeset duplicate-line bursts, shows what it found, and collapses each run to one line once confirmed; `subminer stats cleanup --duplicate-lines` does the same from the terminal, with `--dry-run` and `--lookback-days ` options. Watch time and lines-seen totals are left unchanged. + +### Changed +- **Prerelease Release Notes**: Prerelease notes now open with a "Changes since" section listing only what changed versus the previous beta/RC of the same version, above the cumulative highlights, and CI rejects prerelease tags whose committed notes were generated for a different beta/RC. + +### Fixed +- **Subtitle & Karaoke Duplication**: + - Karaoke and animated signs are reconstructed once from their authored text and shown only while actually sung, with original word spacing preserved, instead of flooding the overlay, subtitle sidebar, immersion history, mining, or stats with glyph fragments, per-frame color phases, and repeated animation events. + - Decorative layers (highlight sweeps, glow/shadow copies, symbol-font decoration, particle swarms, hidden or zero-scaled text) stay out of published text, while ordinary repeated dialogue, positioned signs, wrapped lyric rows, and multi-row CC-style blocks still display correctly. + - Embedded subtitle tracks on network-mounted (SMB/NFS) media are extracted and parsed again instead of falling back to live-text-only, restoring karaoke reconstruction, sidebar cues, and mining for releases that only ship subtitles inside the container. + - Secondary subtitles go through the same deduplication pipeline as primary subtitles and no longer clip display after about four lines. + - Event-heavy karaoke files that previously stalled subtitle loading for several seconds now parse in well under a second. +- **Character Dictionary Reliability**: + - Generation, merged rebuilds, and imports no longer freeze the app on large dictionaries; snapshot I/O, archive building, and image/name lookup caches moved off the UI's critical path. + - Dictionaries are reused instead of regenerated when MeCab finds no name splits. + - Cached portraits restore correctly after the portrait index finishes loading post-tokenization. + - Desktop progress notifications on Linux AppImage installs update in place instead of flickering, fixing a bug where the AppImage's bundled libraries broke the system notification helper. +- **Overlay Startup & Modals**: + - The macOS window-tracking helper targets macOS 12.0+ instead of requiring the build machine's exact macOS version, fixing crashes on older systems like Ventura that left the overlay stuck on "Overlay loading". + - mpv IPC connection attempts time out and retry, showing an actionable error if content still isn't ready after 30 seconds. + - Dedicated overlay modals are prewarmed on macOS and Windows so shortcuts open them promptly. + - On macOS, reused modals and the stats window open above fullscreen mpv on its current Space instead of jumping to another desktop. +- **Wayland File Drop**: Fixed native Wayland drag-and-drop from file managers such as Thunar, so subtitle and video files dropped on the visible overlay are resolved and forwarded to mpv. +- **Windows Mouse Lag**: Fixed system-wide mouse lag on Windows while SubMiner is running, caused by the overlay's global mouse hook for click-through forwarding and by the mpv window tracker blocking the app on repeated PowerShell lookups. +- **Sentence Mining Audio & Clips**: Sentence-audio generation no longer times out on slow network-mounted media with many subtitle/font streams (bounded FFmpeg probing, two-minute extraction budget, clearer error reporting), and mined audio/animated AVIF clips now capture the subtitle line that was actually mined by snapshotting the clip range at lookup time instead of reading live mpv state later. +- **Stats Performance & Reliability**: Immersion stats storage now sets its SQLite busy timeout before WAL setup, avoiding transient lock errors under concurrent writes. Deletes in the stats dashboard no longer freeze the UI, run proportional to what's deleted instead of rebuilding full lifetime summaries, retry safely if the delete worker crashes, and no longer rescan the whole library when deleting very common words; a new index also makes large session deletes drop from minutes to milliseconds. Library merges, video moves, and AniList reassignments got the same lifetime-summary fix. +- **Vocabulary Stats Accuracy**: Vocabulary totals and charts now count all tracked vocabulary instead of only the first page, new-word history uses corrected daily rollups (fixing legacy timestamp and time-zone issues), summary cards refresh automatically after edits to the exclusion list, and rapid exclusion edits no longer race each other. +- **Rofi MKV Thumbnails**: Fixed missing MKV thumbnails in the Linux rofi picker when system thumbnailer registrations only advertise legacy Matroska MIME aliases. + +
+Internal changes + +### Internal +- Docs Site Indexing: Excluded the `/main/` and `/v//` docs trees from search indexing (self-referential canonical, `noindex,follow`, matching `X-Robots-Tag`) so crawlers focus on current docs instead of ~30 archived copies of every page, and restored `` dates in the docs sitemap that were silently dropped by production builds. + +
+ ## v0.19.3 (2026-08-13) ### Added diff --git a/Makefile b/Makefile index dfb5ccfc..09533805 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,10 @@ APP_NAME := subminer THEME_SOURCE := assets/themes/subminer.rasi +THUMBNAILER_SOURCE := assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer LAUNCHER_OUT := dist/launcher/$(APP_NAME) THEME_FILE := subminer.rasi +THUMBNAILER_FILE := subminer-ffmpegthumbnailer.thumbnailer # Default install prefix for the wrapper script. PREFIX ?= $(HOME)/.local @@ -221,11 +223,13 @@ docs-dev: ensure-bun install-linux: build-launcher - @printf '%s\n' "[INFO] Installing Linux wrapper/theme artifacts" + @printf '%s\n' "[INFO] Installing Linux wrapper/support artifacts" @install -d "$(BINDIR)" @install -m 0755 "$(LAUNCHER_OUT)" "$(BINDIR)/$(APP_NAME)" @install -d "$(LINUX_DATA_DIR)/themes" @install -m 0644 "./$(THEME_SOURCE)" "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)" + @install -d "$(LINUX_DATA_DIR)/thumbnailers" + @install -m 0644 "./$(THUMBNAILER_SOURCE)" "$(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)" @install -d "$(LINUX_DATA_DIR)/plugin/subminer" @cp -R ./plugin/subminer/. "$(LINUX_DATA_DIR)/plugin/subminer/" @if [ -n "$(APPIMAGE_SRC)" ]; then \ @@ -234,7 +238,7 @@ install-linux: build-launcher printf '%s\n' "[WARN] No release/SubMiner-*.AppImage found; skipping AppImage install"; \ printf '%s\n' " Build one with: make build"; \ fi - @printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" + @printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)" install-macos: build-launcher @printf '%s\n' "[INFO] Installing macOS wrapper/theme/app artifacts" @@ -275,8 +279,9 @@ uninstall: uninstall-linux: @rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage" @rm -f "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)" + @rm -f "$(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)" @rm -rf "$(LINUX_DATA_DIR)/plugin/subminer" - @printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/plugin/subminer" + @printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)" " $(LINUX_DATA_DIR)/plugin/subminer" uninstall-macos: @rm -f "$(BINDIR)/subminer" diff --git a/README.md b/README.md index b3fef966..4fd7b218 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersio [![License](https://img.shields.io/github/license/ksyasuda/SubMiner?style=flat-square&color=1a1a2e)](https://www.gnu.org/licenses/gpl-3.0) [![TypeScript](https://img.shields.io/badge/TypeScript-1a1a2e?style=flat-square&logo=typescript&logoColor=3178c6)](https://www.typescriptlang.org) -[![SubMiner demo](./assets/minecard.webp)](https://github.com/user-attachments/assets/89e61895-e2b7-4b47-8d50-a35afe4132b2) +[![SubMiner demo](./assets/minecard.webp)](https://github.com/user-attachments/assets/7abab8a9-4e4e-4f06-9f3c-9783e15a3807) diff --git a/assets/minecard-poster.jpg b/assets/minecard-poster.jpg deleted file mode 100644 index b33b3688..00000000 Binary files a/assets/minecard-poster.jpg and /dev/null differ diff --git a/assets/minecard.gif b/assets/minecard.gif deleted file mode 100644 index 989212b3..00000000 Binary files a/assets/minecard.gif and /dev/null differ diff --git a/assets/minecard.jpg b/assets/minecard.jpg deleted file mode 100644 index 0734e8b1..00000000 Binary files a/assets/minecard.jpg and /dev/null differ diff --git a/assets/minecard.mp4 b/assets/minecard.mp4 index 1865a6de..94773a6a 100644 Binary files a/assets/minecard.mp4 and b/assets/minecard.mp4 differ diff --git a/assets/minecard.webm b/assets/minecard.webm deleted file mode 100644 index eaf7e42a..00000000 Binary files a/assets/minecard.webm and /dev/null differ diff --git a/assets/minecard.webp b/assets/minecard.webp index 97400630..f064dda6 100644 Binary files a/assets/minecard.webp and b/assets/minecard.webp differ diff --git a/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer b/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer new file mode 100644 index 00000000..c9bb492f --- /dev/null +++ b/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer @@ -0,0 +1,4 @@ +[Thumbnailer Entry] +TryExec=ffmpegthumbnailer +Exec=ffmpegthumbnailer -i %i -o %o -s %s -f +MimeType=video/matroska;video/matroska-3d;video/x-matroska;video/x-matroska-3d; diff --git a/changes/README.md b/changes/README.md index 667c6845..a3eb3989 100644 --- a/changes/README.md +++ b/changes/README.md @@ -42,13 +42,14 @@ How fragments turn into a release: - At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that. - The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries. -- GitHub release notes and prerelease notes use short top-level items with nested bullets for the change, user benefit, and any useful action note. The stable `CHANGELOG.md` can stay in compact single-line bullets. +- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use short top-level items with one nested bullet per distinct change, instead of packing a release's worth of detail into a single paragraph bullet. An item with only one thing to say stays inline on the top-level bullet. Release notes and prerelease notes additionally cover user benefit and any useful action note in their nested bullets. - `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `
` block) but are dropped from the GitHub release notes entirely. - The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something. Prerelease notes: - prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md` +- from the second prerelease of a base version onward, the notes also open with a `## Changes since ` section generated from the fragment diff against the previous beta/RC tag; keep fragment edits meaningful. Editorial-only rewording is filtered out of that section, while genuinely changed behavior and deleted fragments (reverted changes) are reported - existing prerelease notes are a reviewed baseline; later prerelease runs should replace stale beta/RC wording with the current outcome instead of appending fix churn - prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md` - the final stable release is the point where `bun run changelog:build` consumes fragments into the stable changelog and release notes diff --git a/changes/audio-generation-network-mounts.md b/changes/audio-generation-network-mounts.md deleted file mode 100644 index 30221c75..00000000 --- a/changes/audio-generation-network-mounts.md +++ /dev/null @@ -1,4 +0,0 @@ -type: fixed -area: Anki media - -- Fixed sentence-audio generation timing out on slow network-mounted MKV files with many subtitle and font-attachment streams. Selected audio tracks now use bounded FFmpeg probing and a two-minute extraction budget, and missing output reports a clear FFmpeg error instead of raw `ENOENT`. diff --git a/changes/dictionary-freeze-and-appimage-notifications.md b/changes/dictionary-freeze-and-appimage-notifications.md deleted file mode 100644 index 5bc6e4c9..00000000 --- a/changes/dictionary-freeze-and-appimage-notifications.md +++ /dev/null @@ -1,5 +0,0 @@ -type: fixed -area: dictionary - -- Character dictionary generation, merged rebuilds, and imports no longer freeze the app (and trigger the compositor's "application not responding" dialog) on large dictionaries; snapshot reads/writes, archive building, and the character image/name lookup caches now do their heavy work off the UI's critical path. -- Desktop progress notifications now update in place on Linux AppImage installs too: the AppImage's bundled libraries broke the system notify-send helper, which silently forced the flickering close-and-reopen notification fallback. diff --git a/changes/docs-site-index-hygiene.md b/changes/docs-site-index-hygiene.md deleted file mode 100644 index d988658f..00000000 --- a/changes/docs-site-index-hygiene.md +++ /dev/null @@ -1,5 +0,0 @@ -type: internal -area: docs - -- Excluded the `/main/` and `/v//` docs trees from search indexing with a self-referential canonical, `noindex,follow`, and a matching `X-Robots-Tag` header, so crawlers spend their budget on the current docs instead of ~30 archived copies of every page. -- Restored `` dates in the docs sitemap, which were silently dropped because production builds render from an untracked release snapshot. diff --git a/changes/duplicate-line-stats-cleanup.md b/changes/duplicate-line-stats-cleanup.md deleted file mode 100644 index e6389cfd..00000000 --- a/changes/duplicate-line-stats-cleanup.md +++ /dev/null @@ -1,5 +0,0 @@ -type: fixed -area: stats - -- Typeset subtitles no longer flood the stats. Karaoke openings and animated signs are authored as one subtitle event per animation frame, and immersion tracking counted every frame, which was enough to put an OP lyric at the top of "Top Repeated Words" for good. Lines are now collapsed on the way in using the same rules the subtitle sidebar already applies: matching parsed timings record exactly the cues the sidebar shows, while shifted, changing, or unparsed sources use a strict fallback where identical, contiguous, sub-0.1s lines stop counting after a few frames. Ordinary repeated dialogue and rewatches are unaffected. -- Added a cleanup for stats already affected. The Vocabulary tab has a **Duplicates** button that scans a chosen window (7 days through all time), shows the bursts it found and the word and kanji counts they added, and collapses each run to one line once confirmed. `subminer stats cleanup --duplicate-lines` does the same from the terminal, with `--dry-run` and `--lookback-days `. Only subtitle lines and the vocabulary counts they feed are touched; watch time and lines-seen totals are left as recorded. diff --git a/changes/fix-macos-fullscreen-modal-spaces.md b/changes/fix-macos-fullscreen-modal-spaces.md deleted file mode 100644 index d8ef0f17..00000000 --- a/changes/fix-macos-fullscreen-modal-spaces.md +++ /dev/null @@ -1,5 +0,0 @@ -type: fixed -area: overlay - -- Dedicated overlay modals are prewarmed on macOS and Windows so shortcuts open them promptly on the first press. Windows now refreshes the hidden modal renderer between sessions to keep later modals interactive. On macOS, reused modals and the in-app stats window also open above fullscreen mpv on its current Space instead of appearing on another desktop or forcing a Space change. -- Updated subtitle ASS observation to mpv's current `sub-text/ass` property, removing its deprecation warning. diff --git a/changes/fix-mpv-ipc-connect-stall.md b/changes/fix-mpv-ipc-connect-stall.md deleted file mode 100644 index 8a28dc31..00000000 --- a/changes/fix-mpv-ipc-connect-stall.md +++ /dev/null @@ -1,4 +0,0 @@ -type: fixed -area: overlay - -- Fixed the overlay getting stuck on "Overlay loading" forever when startup stalls: mpv IPC connection attempts now time out and retry, switching sockets aborts obsolete attempts, and the plugin replaces its spinner with an actionable error if overlay content is still not ready after 30 seconds. diff --git a/changes/fix-native-wayland-overlay-file-drop.md b/changes/fix-native-wayland-overlay-file-drop.md deleted file mode 100644 index d368159f..00000000 --- a/changes/fix-native-wayland-overlay-file-drop.md +++ /dev/null @@ -1,4 +0,0 @@ -type: fixed -area: overlay - -- Fixed native Wayland drag-and-drop from file managers such as Thunar so subtitle and video files dropped on the visible overlay are resolved and forwarded to mpv. diff --git a/changes/fix-windows-system-mouse-lag.md b/changes/fix-windows-system-mouse-lag.md deleted file mode 100644 index 178e4f78..00000000 --- a/changes/fix-windows-system-mouse-lag.md +++ /dev/null @@ -1,4 +0,0 @@ -type: fixed -area: overlay - -- Fixed system-wide mouse lag on Windows while SubMiner is running: the overlay no longer installs Electron's global mouse hook for click-through forwarding, and the mpv window tracker no longer blocks the app on repeated PowerShell command-line lookups. diff --git a/changes/hide-feature-demos.md b/changes/hide-feature-demos.md deleted file mode 100644 index 63c1a6dd..00000000 --- a/changes/hide-feature-demos.md +++ /dev/null @@ -1,4 +0,0 @@ -type: docs -area: documentation - -- Hid the unfinished feature demos page from the documentation sidebar while keeping its direct URL available. diff --git a/changes/jellyfin-zero-subtitle-delay.md b/changes/jellyfin-zero-subtitle-delay.md new file mode 100644 index 00000000..92ae32ff --- /dev/null +++ b/changes/jellyfin-zero-subtitle-delay.md @@ -0,0 +1,4 @@ +type: fixed +area: jellyfin + +- Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines. diff --git a/changes/library-merge-and-move.md b/changes/library-merge-and-move.md deleted file mode 100644 index a1668592..00000000 --- a/changes/library-merge-and-move.md +++ /dev/null @@ -1,6 +0,0 @@ -type: added -area: stats - -- Library: duplicate cards for the same show can now be combined. Press "Select" above the library grid, tick the cards, and use "Merge Selected"; the dialog picks which entry to keep and moves every episode onto it. Sessions, mined cards, and watch time are preserved, the emptied entries disappear, and remembered title aliases keep future episodes on the merged card. -- Library: episodes can be reassigned to another library entry from the "→" button on an episode row, which is the fix when one file lands under a stray title (e.g. an episode name parsed as the series). Manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair. Local episodes in the same directory reuse a uniquely corrected destination unless they parse to a title that already has its own library entry, while conflicting seasons or manual destinations are not forced together. Emptying an entry this way removes it and returns to the grid. -- Library: exact AniList title matches with compatible seasons fold duplicate cards automatically. Fuzzy same-AniList matches appear as dismissible "Possible duplicate" reviews instead of changing the library without confirmation; conflicting explicit seasons are left alone. diff --git a/changes/linux-notification-in-place-replace.md b/changes/linux-notification-in-place-replace.md deleted file mode 100644 index 39d9dd56..00000000 --- a/changes/linux-notification-in-place-replace.md +++ /dev/null @@ -1,4 +0,0 @@ -type: fixed -area: notifications - -- Character dictionary progress notifications on Linux now update in place instead of flickering off and reappearing on every status change. diff --git a/changes/mining-clip-range-snapshot.md b/changes/mining-clip-range-snapshot.md deleted file mode 100644 index d31d6176..00000000 --- a/changes/mining-clip-range-snapshot.md +++ /dev/null @@ -1,4 +0,0 @@ -type: fixed -area: anki - -- Mined audio and animated AVIF clips now capture the subtitle line that was actually mined. The clip range is snapshotted once at Yomitan lookup time (and reused for both audio and image), instead of each generator reading the live mpv subtitle when it starts — which clipped whatever line was on screen after slow audio extraction finished, producing too-short or misaligned AVIF clips. diff --git a/changes/stats-delete-fast-incremental.md b/changes/stats-delete-fast-incremental.md deleted file mode 100644 index 76f27342..00000000 --- a/changes/stats-delete-fast-incremental.md +++ /dev/null @@ -1,9 +0,0 @@ -type: fixed -area: stats - -- Stats deletes no longer freeze the stats dashboard: the delete worker module now resolves when running from source, so deletes actually run off the serving thread instead of silently falling back to it. -- Deletes now subtract their exact contribution from lifetime summaries instead of rebuilding them from retained sessions, making delete cost proportional to what is deleted and preserving lifetime totals older than the session retention window. -- If the delete worker crashes, the delete now retries on the current thread instead of failing. -- Library merges, video moves, AniList reassignments, and `subminer stats cleanup -l` also stopped rebuilding lifetime summaries from retained sessions; they now recompute from per-episode history, so those operations are faster and no longer erase lifetime totals older than the session retention window. -- Deleting content that contains very common words no longer rescans every occurrence of those words across the whole library; first/last-seen dates are refreshed with index seeks instead. -- Session deletes on large databases dropped from minutes to milliseconds: an index on the subtitle-line event reference now prevents each deleted session event from scanning the whole subtitle-line table for foreign-key enforcement. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index 8eb3b73d..90f69eb2 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -136,6 +136,8 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn Field names are matched against your Anki note type case-insensitively (an exact match wins, then a lowercase comparison). If a configured field does not exist on the note type, SubMiner skips it without error. +These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names. + Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `
` newline). ### Minimal Config @@ -233,7 +235,7 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) When media is available, mined-card overlay and system notifications include the same current-frame thumbnail. -`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio, while leaving the word audio field unchanged. +`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio in `ankiConnect.fields.audio`, even when `overwriteAudio` is disabled. ## AI Translation @@ -287,6 +289,8 @@ Sentence card creation and audio card marking require a non-empty `ankiConnect.i Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is created directly via AnkiConnect with the sentence, audio, and image filled in. +The dedicated sentence-card and audio-card shortcuts use the Lapis/Kiku-compatible `Sentence` and `SentenceAudio` fields. This does not affect the configured fields used to enrich normal word cards. + To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine. ## Word Card Type (Kiku/Lapis) diff --git a/docs-site/changelog.md b/docs-site/changelog.md index 335aa870..bcbf06e4 100644 --- a/docs-site/changelog.md +++ b/docs-site/changelog.md @@ -1,5 +1,61 @@ # Changelog +## v0.19.5 (2026-08-30) + +**Fixed** + +- **Anki Card Update Progress**: The card-update spinner now stays visible until audio and image updates finish, instead of disappearing early. +- **Anki Word-Card Fields**: Word-card enrichment now writes sentence text and audio to the fields configured in AnkiConnect, while the dedicated sentence-card and audio-card actions keep their existing compatible field names. +- **Overlapping Subtitles**: + - Subtitle lines that start while another line is still on screen now appear alongside it, instead of staying hidden until a track switch or seek. + - Subtitles shown at the same time now stack by their authored screen position, with top signs and song lines above bottom dialogue. + - Half-size ASS furigana is no longer shown as if it were a dialogue line. +- **YouTube Auto Captions**: + - Auto-generated captions now follow their intended timing and two-row roll-up layout. + - Long speech is paged instead of covering the video with a wall of text. + - Explicitly timed sound cues like `[音楽]` no longer cover later dialogue. + +## v0.19.4 (2026-08-25) + +**Added** +- **Library Merge & Move**: Duplicate library cards for the same show can now be combined. Select cards in the library grid and use "Merge Selected" to pick which entry to keep and move every episode onto it, preserving sessions, mined cards, and watch time. Episodes can also be reassigned individually via the "→" button, useful when a file lands under a stray title; manual assignments survive later filename parsing, Jellyfin refreshes, and season repair. Exact AniList title matches with compatible seasons now merge automatically, while fuzzy matches surface as dismissible "Possible duplicate" reviews instead of merging silently. +- **Duplicate Line Cleanup Tool**: The Vocabulary tab's new "Duplicates" button scans a chosen time window (7 days through all time) for old karaoke/typeset duplicate-line bursts, shows what it found, and collapses each run to one line once confirmed; `subminer stats cleanup --duplicate-lines` does the same from the terminal, with `--dry-run` and `--lookback-days ` options. Watch time and lines-seen totals are left unchanged. + +**Changed** +- **Prerelease Release Notes**: Prerelease notes now open with a "Changes since" section listing only what changed versus the previous beta/RC of the same version, above the cumulative highlights, and CI rejects prerelease tags whose committed notes were generated for a different beta/RC. + +**Fixed** +- **Subtitle & Karaoke Duplication**: + - Karaoke and animated signs are reconstructed once from their authored text and shown only while actually sung, with original word spacing preserved, instead of flooding the overlay, subtitle sidebar, immersion history, mining, or stats with glyph fragments, per-frame color phases, and repeated animation events. + - Decorative layers (highlight sweeps, glow/shadow copies, symbol-font decoration, particle swarms, hidden or zero-scaled text) stay out of published text, while ordinary repeated dialogue, positioned signs, wrapped lyric rows, and multi-row CC-style blocks still display correctly. + - Embedded subtitle tracks on network-mounted (SMB/NFS) media are extracted and parsed again instead of falling back to live-text-only, restoring karaoke reconstruction, sidebar cues, and mining for releases that only ship subtitles inside the container. + - Secondary subtitles go through the same deduplication pipeline as primary subtitles and no longer clip display after about four lines. + - Event-heavy karaoke files that previously stalled subtitle loading for several seconds now parse in well under a second. +- **Character Dictionary Reliability**: + - Generation, merged rebuilds, and imports no longer freeze the app on large dictionaries; snapshot I/O, archive building, and image/name lookup caches moved off the UI's critical path. + - Dictionaries are reused instead of regenerated when MeCab finds no name splits. + - Cached portraits restore correctly after the portrait index finishes loading post-tokenization. + - Desktop progress notifications on Linux AppImage installs update in place instead of flickering, fixing a bug where the AppImage's bundled libraries broke the system notification helper. +- **Overlay Startup & Modals**: + - The macOS window-tracking helper targets macOS 12.0+ instead of requiring the build machine's exact macOS version, fixing crashes on older systems like Ventura that left the overlay stuck on "Overlay loading". + - mpv IPC connection attempts time out and retry, showing an actionable error if content still isn't ready after 30 seconds. + - Dedicated overlay modals are prewarmed on macOS and Windows so shortcuts open them promptly. + - On macOS, reused modals and the stats window open above fullscreen mpv on its current Space instead of jumping to another desktop. +- **Wayland File Drop**: Fixed native Wayland drag-and-drop from file managers such as Thunar, so subtitle and video files dropped on the visible overlay are resolved and forwarded to mpv. +- **Windows Mouse Lag**: Fixed system-wide mouse lag on Windows while SubMiner is running, caused by the overlay's global mouse hook for click-through forwarding and by the mpv window tracker blocking the app on repeated PowerShell lookups. +- **Sentence Mining Audio & Clips**: Sentence-audio generation no longer times out on slow network-mounted media with many subtitle/font streams (bounded FFmpeg probing, two-minute extraction budget, clearer error reporting), and mined audio/animated AVIF clips now capture the subtitle line that was actually mined by snapshotting the clip range at lookup time instead of reading live mpv state later. +- **Stats Performance & Reliability**: Immersion stats storage now sets its SQLite busy timeout before WAL setup, avoiding transient lock errors under concurrent writes. Deletes in the stats dashboard no longer freeze the UI, run proportional to what's deleted instead of rebuilding full lifetime summaries, retry safely if the delete worker crashes, and no longer rescan the whole library when deleting very common words; a new index also makes large session deletes drop from minutes to milliseconds. Library merges, video moves, and AniList reassignments got the same lifetime-summary fix. +- **Vocabulary Stats Accuracy**: Vocabulary totals and charts now count all tracked vocabulary instead of only the first page, new-word history uses corrected daily rollups (fixing legacy timestamp and time-zone issues), summary cards refresh automatically after edits to the exclusion list, and rapid exclusion edits no longer race each other. +- **Rofi MKV Thumbnails**: Fixed missing MKV thumbnails in the Linux rofi picker when system thumbnailer registrations only advertise legacy Matroska MIME aliases. + +
+Internal changes + +**Internal** +- Docs Site Indexing: Excluded the `/main/` and `/v//` docs trees from search indexing (self-referential canonical, `noindex,follow`, matching `X-Robots-Tag`) so crawlers focus on current docs instead of ~30 archived copies of every page, and restored `` dates in the docs sitemap that were silently dropped by production builds. + +
+ ## v0.19.3 (2026-08-13) **Added** diff --git a/docs-site/demos.md b/docs-site/demos.md index 4892e1c8..e0097d4e 100644 --- a/docs-site/demos.md +++ b/docs-site/demos.md @@ -5,7 +5,7 @@ Short recordings of SubMiner's key features and integrations from real playback ## Anki Card Mining & Enrichment diff --git a/docs-site/development.md b/docs-site/development.md index 6ffdc59c..d64e929d 100644 --- a/docs-site/development.md +++ b/docs-site/development.md @@ -6,7 +6,7 @@ For internal architecture/workflow guidance, use `docs/README.md` at the repo ro - [Bun](https://bun.sh) - A system `lua` interpreter for `bun run test:launcher` / `bun run test:plugin:src` -- macOS builds compile a Swift helper via `scripts/build-macos-helper.sh` (skip with `SUBMINER_SKIP_MACOS_HELPER_BUILD=1`) +- macOS builds compile a Swift helper via `scripts/prepare-build-assets.mjs` (skip with `SUBMINER_SKIP_MACOS_HELPER_BUILD=1`) ## Setup diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index 6147d8d7..1735a2b7 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -82,7 +82,7 @@ Expandable session history with new-word activity, cumulative totals, and pause/ #### Vocabulary -Top repeated words (click a bar to open the word), new-word timeline, cross-title and frequency rank tables with Hide Known / Hide Kana filters, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons. +The summary cards show all unique vocabulary and kanji recorded in the local tracking database; **New This Week** is the only weekly figure and uses a rolling seven-day window. The word and kanji tables load first while those complete totals calculate separately. Top Repeated Words and New Words by Day use complete tracking history rather than the table's browsing page. New-word history is maintained as a permanent daily lexical rollup using the same token-visibility rules as the totals, including normalization of older timestamps stored in either seconds or milliseconds and retroactive corrections when tracked material is removed or reprocessed. On the first launch after an applicable upgrade, that history is version-rebuilt in the background and the chart refreshes when it is ready; if it remains unavailable, polling stops and an inline Retry control appears. The cards and charts also refresh automatically after the word exclusion list changes. The rest of the tab includes cross-title and frequency rank tables with Hide Known / Hide Kana filters, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons. ![Stats Vocabulary](/screenshots/stats-vocabulary.png) @@ -138,6 +138,8 @@ Karaoke openings and animated signs are authored as one subtitle event per anima Recording now collapses those runs as they happen, matching what the subtitle sidebar shows: +- When a typeset ASS file stores a clean lyric or sign in a timed authoring comment, or in full-line events surrounding generated fragments, the matching complete line is recorded once. The repeated glyph or clip-animation frames are not recorded. Dialogue spoken while such an animation is on screen records as itself, without the fragment lines beside it. +- When karaoke styling redraws the same complete lyric across consecutive color or highlight phases, those phases are combined into one line with their full timing. Repeated ordinary dialogue remains separate. - When the active subtitle source has been parsed, its cue list has already had duplicate events and animation bursts merged. A line landing inside a surviving cue but after that cue's start is a frame the sidebar merged away, and is not recorded. - When no parsed cue covers the live timing, including while a subtitle source is changing or shifted, the strict metadata-free rule applies: a run of identical, contiguous lines each shorter than 0.1s stops being recorded after a few frames. Runs are tracked per line of text, so dual-line karaoke (a kanji and a romaji line frame-flipped together) collapses both lines. Ordinary repeated dialogue, and lines held for a normal beat, always record. @@ -180,6 +182,7 @@ In practice: - Anime and episode pages keep lifetime totals from summary tables while session drill-down still reads retained sessions directly. With the current defaults, both are kept forever. - Trends can read the full available history because daily/monthly rollups are also kept forever by default. - Vocabulary and kanji totals are cumulative and not bounded by the raw session retention knobs. +- New-word charts use their own permanent lexical daily rollups, which are not pruned by activity-rollup retention. ## Storage / Performance Model @@ -349,6 +352,7 @@ Rollup tables: - `imm_daily_rollups` - `imm_monthly_rollups` +- `imm_lexical_daily_rollups` - permanent first-discovery counts for vocabulary and kanji chart history - `imm_rollup_state` - incremental rollup progress bookkeeping Vocabulary tables: diff --git a/docs-site/index.md b/docs-site/index.md index 2454169c..1a3b2d19 100644 --- a/docs-site/index.md +++ b/docs-site/index.md @@ -88,7 +88,7 @@ features:
diff --git a/docs-site/installation.md b/docs-site/installation.md index 10278a37..2ab66030 100644 --- a/docs-site/installation.md +++ b/docs-site/installation.md @@ -392,7 +392,7 @@ subminer -u subminer --update ``` -SubMiner verifies AppImage, launcher, and Linux support-asset downloads against `SHA256SUMS.txt`. On Linux those support assets include the launcher-managed runtime plugin copy under `SubMiner/plugin/subminer` plus the rofi theme at `SubMiner/themes/subminer.rasi`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself. +SubMiner verifies AppImage, launcher, and Linux support-asset downloads against `SHA256SUMS.txt`. On Linux those support assets include the launcher-managed runtime plugin copy under `SubMiner/plugin/subminer`, the rofi theme at `SubMiner/themes/subminer.rasi`, and the scoped Matroska thumbnailer registration under `SubMiner/thumbnailers`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself. The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater` and refreshes the managed support assets from `subminer-assets.tar.gz`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge. @@ -404,7 +404,7 @@ SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IP The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) - without it the overlay starts but subtitles won't appear. -The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. On Linux, the `subminer` launcher now checks for its managed runtime plugin copy and rofi theme before every mpv-managed launch and installs those support assets from the bundled app automatically if either one is missing. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference. +The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. On Linux, the `subminer` launcher checks for its managed runtime plugin copy, rofi theme, and scoped thumbnailer registration before every mpv-managed launch and installs those support assets from the bundled app automatically if one is missing. For a rofi picker launch, this check runs before the picker opens. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference. ## Platform Notes @@ -456,18 +456,20 @@ sudo chmod +x /usr/local/bin/subminer ### Linux Support Assets -SubMiner ships the Linux rofi theme plus the launcher-managed runtime plugin copy in `subminer-assets.tar.gz`: +SubMiner ships the Linux rofi theme, scoped Matroska thumbnailer registration, and launcher-managed runtime plugin copy in `subminer-assets.tar.gz`: ```bash wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz tar -xzf /tmp/subminer-assets.tar.gz -C /tmp mkdir -p ~/.local/share/SubMiner/themes cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi +mkdir -p ~/.local/share/SubMiner/thumbnailers +cp /tmp/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer ~/.local/share/SubMiner/thumbnailers/ mkdir -p ~/.local/share/SubMiner/plugin cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer ``` -`subminer -u` and the tray updater keep those Linux support assets in sync automatically once the `SubMiner` data dir exists. Normal Linux launcher playback also auto-installs the managed runtime plugin copy and rofi theme from the bundled app if either support asset is missing, so manual extraction is mainly useful for pre-seeding or custom setups. +`subminer -u` and the tray updater keep those Linux support assets in sync automatically once the `SubMiner` data dir exists. Normal Linux launcher playback also auto-installs all three assets from the bundled app if one is missing, so manual extraction is mainly useful for pre-seeding or custom setups. Rofi receives the SubMiner data path through its process-local `XDG_DATA_DIRS`, so the thumbnailer registration does not change the desktop-wide configuration. Override the theme path with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`. diff --git a/docs-site/jellyfin-integration.md b/docs-site/jellyfin-integration.md index 064cdc18..a07197a2 100644 --- a/docs-site/jellyfin-integration.md +++ b/docs-site/jellyfin-integration.md @@ -54,7 +54,7 @@ From then on, pause / resume / seek / stop and audio or subtitle track changes y - **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load. - **Direct play first.** When the source allows it and the container is in your direct-play allowlist, SubMiner streams the original file; otherwise it requests a transcoded stream from Jellyfin. - **Japanese subtitles are auto-selected,** preferring Jellyfin's default and embedded tracks over external sidecar files when several match. -- **Subtitle timing is corrected when possible.** SubMiner removes Jellyfin's server-selected subtitle stream from the mpv load URL, suppresses the mpv plugin's one-shot subtitle auto-selection and overlay auto-start for managed Jellyfin loads, stages downloaded subtitle tracks without letting mpv auto-switch between tracks, then selects the Japanese track once after applying any saved or inferred timing delay. When Jellyfin provides both Japanese and English subtitle files, SubMiner compares their cue timelines and applies a global delay if one track is clearly offset. Manual delay shifts you make with SubMiner's adjacent-cue controls are saved per item and subtitle track, then restored the next time you select that track. +- **Downloaded subtitles keep their original timing.** SubMiner removes Jellyfin's server-selected subtitle stream from the mpv load URL, suppresses the mpv plugin's one-shot subtitle auto-selection and overlay auto-start for managed Jellyfin loads, stages the subtitle files exposed by Jellyfin without letting mpv auto-switch between tracks, resets mpv's subtitle delay to zero, then selects the Japanese track. SubMiner does not compare Japanese and English cue timelines or save an inferred delay. ## Settings diff --git a/docs-site/launcher-script.md b/docs-site/launcher-script.md index e45b7c32..0310eec6 100644 --- a/docs-site/launcher-script.md +++ b/docs-site/launcher-script.md @@ -34,18 +34,22 @@ subminer -R -r -d ~/Anime # rofi picker, recursive subminer -R /directory # rofi picker, directory shortcut ``` -rofi shows a GUI menu with icon thumbnails when available. SubMiner ships the rofi theme plus the Linux launcher-managed runtime plugin copy in the release assets tarball: +rofi shows a GUI menu with icon thumbnails when available. SubMiner ships the rofi theme, a scoped `ffmpegthumbnailer` MIME registration, and the Linux launcher-managed runtime plugin copy in the release assets tarball: ```bash wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz tar -xzf /tmp/subminer-assets.tar.gz -C /tmp mkdir -p ~/.local/share/SubMiner/themes cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi +mkdir -p ~/.local/share/SubMiner/thumbnailers +cp /tmp/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer ~/.local/share/SubMiner/thumbnailers/ mkdir -p ~/.local/share/SubMiner/plugin cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer ``` -Once the `SubMiner` data dir exists, `subminer -u` refreshes both assets automatically. Normal Linux launcher playback also checks for the managed runtime plugin copy and rofi theme before mpv launch and installs them from the bundled app automatically if either one is missing. +Once the `SubMiner` data dir exists, `subminer -u` refreshes these assets automatically. Normal Linux launcher playback checks for all three assets and installs them from the bundled app when one is missing. For `subminer -R`, this repair runs before rofi opens. + +When `ffmpegthumbnailer` is installed, SubMiner prepends its own data directory to `XDG_DATA_DIRS` for the rofi process only. This lets rofi recognize the canonical Matroska MIME types used by newer GLib versions without changing the desktop-wide MIME or thumbnailer configuration. An existing registration in your own `$XDG_DATA_HOME/thumbnailers` still takes priority. The theme is auto-detected from these paths (first match wins): diff --git a/docs-site/mining-workflow.md b/docs-site/mining-workflow.md index a9065149..79d1b332 100644 --- a/docs-site/mining-workflow.md +++ b/docs-site/mining-workflow.md @@ -41,7 +41,7 @@ If you prefer a hands-on approach (animecards-style), you can copy the current s - For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1`–`9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard. 3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation - the same fields auto-update would fill. -Manual clipboard updates always replace generated sentence audio, even when `ankiConnect.behavior.overwriteAudio` is disabled. The word audio field is left unchanged because the word itself does not change in this flow. +Manual clipboard updates always replace generated sentence audio in `ankiConnect.fields.audio`, even when `ankiConnect.behavior.overwriteAudio` is disabled. Normal word-card updates use the configured sentence and audio fields even when Lapis or Kiku support is enabled. This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card. @@ -108,8 +108,12 @@ The secondary bar is a compact top-strip region in the same overlay window. It s - Quick comprehension checks without leaving the mining flow. - Auto-populating the translation field on mined cards - when a card is created, SubMiner uses the secondary subtitle text as the translation field value (unless AI translation is configured to override it). +For local media, SubMiner can parse supported embedded secondary tracks into timed cues. For remote URLs and files on network mounts, it uses mpv's live secondary subtitle text instead of scanning the media with ffmpeg. + It is controlled by `secondarySub` configuration and shares its lifecycle with the main overlay window. Cycle which track feeds it with `Shift+J`. +SubMiner collapses duplicate ASS layers in parsed secondary tracks. Exact repeated lines collapse at any length, while distinct simultaneous short lines remain separate. Long dialogue and positioned-sign copies also collapse when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts, such as translated timetables, are excluded instead of being concatenated into the secondary bar. + ### Display Modes Both the primary and secondary subtitle bars share the same three visibility modes, and each can be changed independently at runtime: diff --git a/docs-site/public/assets/minecard-poster.jpg b/docs-site/public/assets/minecard-poster.jpg index c338624e..68228757 100644 Binary files a/docs-site/public/assets/minecard-poster.jpg and b/docs-site/public/assets/minecard-poster.jpg differ diff --git a/docs-site/public/assets/minecard.gif b/docs-site/public/assets/minecard.gif deleted file mode 100644 index 989212b3..00000000 Binary files a/docs-site/public/assets/minecard.gif and /dev/null differ diff --git a/docs-site/public/assets/minecard.jpg b/docs-site/public/assets/minecard.jpg deleted file mode 100644 index 0734e8b1..00000000 Binary files a/docs-site/public/assets/minecard.jpg and /dev/null differ diff --git a/docs-site/public/assets/minecard.mkv b/docs-site/public/assets/minecard.mkv deleted file mode 100644 index 65cf05d7..00000000 Binary files a/docs-site/public/assets/minecard.mkv and /dev/null differ diff --git a/docs-site/public/assets/minecard.mp4 b/docs-site/public/assets/minecard.mp4 index 1865a6de..94773a6a 100644 Binary files a/docs-site/public/assets/minecard.mp4 and b/docs-site/public/assets/minecard.mp4 differ diff --git a/docs-site/public/assets/minecard.png b/docs-site/public/assets/minecard.png deleted file mode 100644 index 3d8c767a..00000000 Binary files a/docs-site/public/assets/minecard.png and /dev/null differ diff --git a/docs-site/public/assets/minecard.webm b/docs-site/public/assets/minecard.webm index eaf7e42a..580e3bd3 100644 Binary files a/docs-site/public/assets/minecard.webm and b/docs-site/public/assets/minecard.webm differ diff --git a/docs-site/public/assets/minecard.webp b/docs-site/public/assets/minecard.webp index 97400630..f064dda6 100644 Binary files a/docs-site/public/assets/minecard.webp and b/docs-site/public/assets/minecard.webp differ diff --git a/docs-site/subtitle-sidebar.md b/docs-site/subtitle-sidebar.md index e5203085..4478098b 100644 --- a/docs-site/subtitle-sidebar.md +++ b/docs-site/subtitle-sidebar.md @@ -9,9 +9,11 @@ The sidebar is enabled by default. Set `subtitleSidebar.enabled` to `false` if y When SubMiner parses the active subtitle source into a cue list, the sidebar becomes available. Toggle it with the `\` key (configurable via `subtitleSidebar.toggleKey`). While open: - The active cue is highlighted and kept in view as playback advances (when `autoScroll` is `true`). -- Clicking any cue seeks mpv to that timestamp. +- Clicking any cue seeks mpv into that line. For overlapping ASS karaoke, SubMiner moves past the previous line's exit animation when the selected cue has enough time remaining. - The sidebar stays synchronized with the overlay - media transitions and subtitle source changes update both simultaneously. +For typeset ASS karaoke and animated signs, SubMiner collapses generated animation frames and repeated full-line color phases before they reach the sidebar. It recovers a clean complete line from a matching timed authoring comment or from full-line events surrounding generated fragments. Ordinary ASS comments, editor notes, alternate lines, repeated dialogue, and separately positioned signs remain distinct. + The sidebar only appears when a parsed cue list is available. External subtitle sources that SubMiner cannot parse (for example, embedded ASS tracks rendered directly by mpv) will not populate the sidebar. ## Layout Modes diff --git a/docs/RELEASING.md b/docs/RELEASING.md index f815bebe..cdc4e8e6 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -58,12 +58,15 @@ `latest*.yml` and `*.blockmap` files under `release/`. 5. Commit the prerelease prep (package.json version bump + the generated `release/prerelease-notes.md`). CI does not regenerate notes — it uses the - committed file — so review it before committing. If you add more - `changes/*.md` fragments for a later beta/RC, rerun - `bun run changelog:prerelease-notes --version `; the generator uses - the existing prerelease notes as the baseline only when their hidden - `prerelease-base-version` marker matches the current base version, and asks - Claude to merge only the new fragment material. Do not run + committed file — so review it before committing. Rerun + `bun run changelog:prerelease-notes --version ` for every later + beta/RC, even if no fragments changed: the notes carry a hidden + `prerelease-version` marker and CI rejects the tag when the marker does not + match it (verify locally with + `bun run changelog:check-prerelease-notes --version `). The + generator reuses the existing notes as the cumulative baseline when their + marker (or legacy `prerelease-base-version` marker) matches the current base + version, and asks Claude to merge only the new fragment material. Do not run `bun run changelog:build`. 6. Tag the commit: `git tag v`. 7. Push commit + tag. @@ -78,6 +81,8 @@ Notes: - Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night. - `changelog:check` now rejects tag/package version mismatches. - `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over. +- From the second prerelease of a base version onward, the notes open with a `## Changes since ` section above the cumulative `## Highlights`. The generator locates the newest preceding beta/RC tag for the same base version (semver order: all betas before all RCs), diffs `changes/*.md` between that tag and the working tree, and asks Claude to describe only the behavioral beta-to-beta differences — added fragments as new changes, modified fragments by their before/after difference (editorial-only edits are dropped), deleted fragments as removed/reverted changes. If no fragments changed (for example a packaging-only rebuild), the section states that explicitly without a Claude call. The delta section carries no separate contributor attribution; `## What's Changed` stays cumulative like `## Highlights`. +- `changelog:check-prerelease-notes --version ` verifies the committed notes' `prerelease-version` marker matches the version being tagged; the prerelease workflow runs it and fails the release on stale notes. - `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `
Internal changes` collapse; the release notes drop them entirely. - `release/release-notes.md` (and `release/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @ in #`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits//pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free. - The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version ` locally, commit the polished output, then tag. diff --git a/docs/architecture/2026-03-15-renderer-performance-design.md b/docs/architecture/2026-03-15-renderer-performance-design.md index d430d916..809d6b8b 100644 --- a/docs/architecture/2026-03-15-renderer-performance-design.md +++ b/docs/architecture/2026-03-15-renderer-performance-design.md @@ -70,18 +70,27 @@ interface SubtitleCue { startTime: number; // seconds endTime: number; // seconds text: string; // plain text, decoded from the source format + source?: 'canonical-ass'; // recovered authored text for generated ASS animation + animationStartTime?: number; // full generated-frame envelope; entrance/exit frames + animationEndTime?: number; // run past the authored timing, live matching uses this } ``` **Supported formats:** - SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks. -- ASS: Parse `[Events]` section, extract `Dialogue:` lines, read the field order from the `Format:` row, and take everything after the Text field index as the text (Text can itself contain commas). +- ASS: Parse the `[Events]` section, read the field order from the `Format:` row, and extract timed `Dialogue:` lines. Timed `Comment:` lines are normally ignored, but can supply canonical authored text when they match a nearby generated animation from the same style and actor. Text can itself contain commas. **ASS decoding.** The parser is where ASS text is decoded, once, via `assToPlainText()` in `src/core/services/ass-text.ts`. That decoder mirrors mpv's `ass_to_plaintext` so a cue read from a file reads identically to the same line arriving live on `sub-text`: `{...}` override blocks are markup, `\pN … \p0` vector drawing runs are dropped rather than shown as text, `\N`/`\n`/`\h` are the only escapes (`\{`, `\}` and `\\` are not), and an unclosed `{` is rendered verbatim. Every layer downstream — renderer, timing tracker, tokenizer, tokenization cache keys — receives plain text and uses `normalizePlainSubtitleText()` for whitespace only, so nothing decodes the same string twice and one authored line always maps to one cache key. **Duplicate collapsing.** Typeset scripts emit one `Dialogue:` event per animation frame, plus layered copies of the same line. The parser collapses identical text over an identical span unconditionally, and collapses contiguous same-text runs of at least three events when the run looks like an animation. For ASS that means shared style and actor plus authoring evidence: a temporal tag (`\t`, `\move`, `\k`/`\kf`/`\ko`/`\K`, or anything wrapped in `\t(...)`), an animated `Effect` column (`Karaoke`, `Banner`, `Scroll`), or override values that change across the run. Static tags shared by every event (`\pos`, an identical `\clip`) are not evidence. SRT/VTT carry no such metadata, so there collapsing needs at least five contiguous events all under 0.1s — the frame timing left behind by ASS-to-SRT conversion. The parser keeps this authoring metadata (style, actor, layer, `Effect`, parsed override commands, source order) private; `parseSubtitleCues()` returns only `SubtitleCue`. +ASS scripts can also redraw one complete lyric for two or more long color/highlight phases. Those flush-timed phases collapse separately from short animation frames when they share text, style, actor, and layer and carry direct animation evidence, such as temporal tags or changing non-spatial overrides. Spatial command changes do not prove a phase, so separately positioned signs remain distinct. + +**Canonical animation recovery.** Some ASS producers keep the readable lyric or sign as a timed `Comment:` and generate hundreds of `Dialogue:` frames containing repeated glyphs or changing clip regions. Others retain the complete line as brief `Dialogue:` events around the generated fragments. A complete event is promoted only when nearby dialogue from the same style and actor forms a proven animation cluster and reconstructs its entire text in source order. The generated frames are then replaced by one cue marked `source: 'canonical-ass'`. This source marker lets the live primary-subtitle path prefer the clean authored text and timing for display, sidebar history, immersion recording, and mining, while unmatched editor notes and alternative translations remain ignored. Secondary selection advances to an entering canonical cue at its generated animation start when the preceding authored cue ends before the new authored span. Unrelated simultaneous cues that continue through the new span remain visible. + +**Font texture cleanup.** A clipped repeated-glyph run or frequent changes to secondary alpha marks a texture seed. Clipped runs do not need a font override because some signs build their masks from ordinary `l` glyphs. The parser removes short clipped pieces that share a no-font seed's style and timing, or pieces that share a font seed's style, timing, and font even when the actor changes. It also removes positioned text layers with at least `E0` global alpha when they overlap a seed in the same style. Opaque authored sign text stays publishable when the texture switches fonts or actors around it. + #### Prefetch Service Lifecycle 1. **Activation trigger:** When a subtitle track is activated (or changes), check if it's external via MPV's `track-list` property. If `external === true`, read the file via `external-filename` using the existing `loadSubtitleSourceText` infrastructure. diff --git a/docs/architecture/stats-trends-data-flow.md b/docs/architecture/stats-trends-data-flow.md index 8c7d27bb..d0a6624b 100644 --- a/docs/architecture/stats-trends-data-flow.md +++ b/docs/architecture/stats-trends-data-flow.md @@ -23,7 +23,9 @@ Trend charts now consume one chart-oriented backend payload from `/api/stats/tre - lookup rate trends - watch-time by day-of-week/hour - vocabulary-backed: - - new-words trend + - new-words trend reads permanent daily lexical rollups + - rollup rows count only vocabulary-visible tokens and normalize mixed legacy timestamp units + - a persisted rollup version invalidates stale materializations and triggers an atomic background rebuild ## Metric Semantics diff --git a/docs/architecture/subtitle-overlay-priming.md b/docs/architecture/subtitle-overlay-priming.md index e5ed4fc9..1ca20a16 100644 --- a/docs/architecture/subtitle-overlay-priming.md +++ b/docs/architecture/subtitle-overlay-priming.md @@ -3,7 +3,7 @@ # Subtitle Overlay Priming Status: active -Last verified: 2026-08-04 +Last verified: 2026-08-19 Owner: Kyle Yasuda Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated @@ -69,14 +69,85 @@ coming and prefetching would otherwise idle for the rest of the cue. ## Live Cue Delivery +- Primary live text first resolves recovered canonical ASS animations. Otherwise, when + every live mpv line matches an active parsed cue, it uses the parsed cue text so exact + full-span style layers appear once instead of repeating for fill, border, blur, shadow, + or equivalent whitespace variants. Any unmatched live line keeps the complete live + stack, preserving dialogue or signs that overlap a lyric. - A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so live work does not contend for Yomitan state. +- The initial `time-pos`, explicit renderer seeks, and later seek-like jumps reprocess mpv's + current raw `sub-text` after the new playback time is stored. Explicit intent matters because + adjacent subtitle jumps can be shorter than the general seek-distance threshold. This corrects + ASS cleanup when mpv delivered the destination subtitle before the destination timestamp. +- Renderer `sub-seek` commands use the active parsed cue list when available. Simultaneous cues + share one boundary, overlapping lyrics advance from the latest active boundary, and mpv's native + command remains the fallback when no parsed destination exists. This prevents generated karaoke + frames from consuming next/previous subtitle presses. +- Subtitle sidebar selections seek past the preceding sanitized cue's overlapping exit span when + the selected cue has enough time remaining. This keeps direct row selection on the requested + karaoke line while clamping the seek inside that cue. +- If startup paints raw text before embedded ASS parsing finishes, parsed cue arrival may replace + that provisional line. The one-prime-per-media guard still suppresses identical repeats. - 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. +## Secondary Subtitle Flow + +- `secondary-sub-text` remains the immediate fallback, so unreadable subtitle sources, remote URLs, + and still-extracting embedded tracks appear without waiting for file resolution. Embedded-track + extraction runs for local and network-mounted files alike (demuxing reads the whole container, + about 10 seconds per GB on gigabit, under a generous timeout); only true remote URLs skip it, + having no on-disk container to demux. +- The live fallback also suppresses per-glyph typesetting walls: when many simultaneous + one-glyph lines are present (generated karaoke lettering flattened into live text), those + lines and their short syllable companions are dropped while concurrent dialogue lines stay. + This keeps the overlay clean while extraction is still in flight and for sources that never + produce parsed cues. +- Parsed secondary text and the live fallback remove exact repeated lines at any length. A + flattened-line identity also removes long dialogue/sign repetitions that differ only in + whitespace or terminal punctuation, while distinct simultaneous short lines remain separate. +- `secondary-subtitle-track.ts` resolves `secondary-sid` against mpv's track list. External tracks + are read directly; supported embedded text tracks are extracted through the same ffmpeg-backed + source resolver used by primary subtitle prefetching. +- The selected source is parsed with `parseSubtitleCues()`, including metadata-aware ASS duplicate + and animation collapse. Playback `time-pos` selects the active parsed cue after applying + `secondary-sub-delay`. +- Fragment reconstruction marks tall multi-row positioned parts as a grid only when they read + like tiling: a couple of texts repeated across many fragments, the same text re-shown at one + spot over time (countdown/animation frames), or scattered single glyphs. Secondary text omits + those grids instead of flattening a translated table or schedule into one synthetic line. + Wrapped lyric rows, CC-style dialogue blocks, and reconstructed single-line karaoke remain + eligible for display. +- The resolved text is stored in `mpvClient.currentSecondarySubText` before it is broadcast. The + overlay, mining, timing tracker, and immersion statistics therefore consume the same secondary + text when a readable source is available. +- Simultaneous parsed cues use whitespace-insensitive identity, so ASS layers that vary only + between ordinary, hard, or ideographic spaces appear once. +- Simultaneous ASS lines are flattened in top-to-bottom positioned order, falling back to their + authored source order when no usable position exists. +- Half-size kana positioned directly above a same-timed kanji caption is treated as ASS + furigana. The parser omits it from published cues but retains hidden matching metadata so + mpv's raw live text can be reconciled without displaying or mining the reading. +- Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces + survive concatenation. Latin fragment typesetting with no literal spaces also recovers word + boundaries represented only by materially larger horizontal `\pos` or `\move` gaps within that + line. Unpositioned fragments stay compact instead of gaining guessed spaces between syllables. + Short runs qualify only when overlapping positioned events also show changing overrides or + repeated layer copies; an English or romaji style name alone never turns ordinary dialogue into + a lyric. +- Recovered canonical ASS text remains active for the generated animation envelope. For + reconstructed lyric styles, the longest-lived active line wins over brief entrance and exit + fragments from the same style. +- Media and `secondary-sid` changes clear the previous parsed state before refreshing the source; + track-list changes refresh without discarding an unchanged source. Observed + `secondary-sub-delay` changes retime the active parsed cue without rereading the file. If loading, + extraction, or parsing fails, the controller returns to live mpv text and the renderer's + conservative short stack heuristic remains the final display fallback. + ## Emitted State - `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation @@ -84,8 +155,8 @@ coming and prefetching would otherwise idle for the rest of the cue. - 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 - `mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows. +- Secondary priming reads mpv `secondary-sub-text` and routes it through the secondary track + controller. A parsed active cue replaces the live text when the selected source is readable. - If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is written. diff --git a/docs/knowledge-base/catalog.md b/docs/knowledge-base/catalog.md index 352865b6..d0120cad 100644 --- a/docs/knowledge-base/catalog.md +++ b/docs/knowledge-base/catalog.md @@ -19,7 +19,7 @@ Read when: finding internal docs or checking verification status | Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps | | Workflow index | `docs/workflow/README.md` | active | 2026-08-13 | execution map | | Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans | -| Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-13 | repo-local workflow skill ownership | +| Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-23 | repo-local workflow skill ownership | | Verification guide | `docs/workflow/verification.md` | active | 2026-08-13 | maintained verification lanes | | Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist | diff --git a/docs/workflow/agent-skills.md b/docs/workflow/agent-skills.md index 5d87ec2e..aed51d81 100644 --- a/docs/workflow/agent-skills.md +++ b/docs/workflow/agent-skills.md @@ -3,7 +3,7 @@ # Agent Skills Status: active -Last verified: 2026-08-13 +Last verified: 2026-08-23 Owner: Kyle Yasuda Read when: using, adding, or changing a repo-local agent workflow skill @@ -12,6 +12,9 @@ Read when: using, adding, or changing a repo-local agent workflow skill - `.agents/skills/subminer-change-verification/` - Selects the cheapest sufficient repo-native verification lane. - Defers command ownership to `package.json` and `docs/workflow/verification.md`. +- `.agents/skills/subminer-release/` + - Prepares, cuts, publishes, or repairs stable and prerelease releases. + - Defers release procedure and policy to `docs/RELEASING.md`. Repo-local workflows stay as standalone skills. Do not add plugin packaging, marketplace metadata, or compatibility shims unless the workflow is intentionally being distributed beyond this repository. diff --git a/launcher/commands/history-command.ts b/launcher/commands/history-command.ts index 352bbee5..37d10be8 100644 --- a/launcher/commands/history-command.ts +++ b/launcher/commands/history-command.ts @@ -23,6 +23,7 @@ import { type HistorySeriesEntry, } from '../history.js'; import type { Args } from '../types.js'; +import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js'; import type { LauncherCommandContext } from './context.js'; export type HistorySessionAction = 'previous' | 'replay' | 'next' | 'browse' | 'quit'; @@ -333,6 +334,13 @@ export async function runHistoryCommand( const { args, scriptPath } = context; checkPickerDependencies(args); + if (args.useRofi) { + await ensureLinuxRuntimePluginAvailable({ + appPath: context.appPath ?? undefined, + scriptPath, + logLevel: args.logLevel, + }); + } const themePath = args.useRofi ? findRofiTheme(scriptPath) : null; const dbPath = resolveImmersionDbPath(); diff --git a/launcher/commands/jellyfin-command.ts b/launcher/commands/jellyfin-command.ts index cc22b0f1..f4fe2b94 100644 --- a/launcher/commands/jellyfin-command.ts +++ b/launcher/commands/jellyfin-command.ts @@ -2,6 +2,7 @@ import { fail } from '../log.js'; import { runAppCommandWithInherit } from '../mpv.js'; import { commandExists } from '../util.js'; import { runJellyfinPlayMenu } from '../jellyfin.js'; +import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js'; import { shouldForwardLogLevel } from '../types.js'; import type { LauncherCommandContext } from './context.js'; @@ -64,6 +65,13 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi if (args.useRofi && !commandExists('rofi')) { fail('rofi not found. Install rofi or omit -R for fzf.'); } + if (args.useRofi) { + await ensureLinuxRuntimePluginAvailable({ + appPath, + scriptPath, + logLevel: args.logLevel, + }); + } await runJellyfinPlayMenu(appPath, args, scriptPath, mpvSocketPath); return true; } diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index ffae2d8a..9ef761ca 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -496,3 +496,39 @@ test('playback command ensures Linux runtime plugin before mpv launch', async () assert.deepEqual(calls, ['plugin', 'startMpv']); }); + +test('rofi playback repairs support assets before opening the picker', async () => { + const context = createContext(); + context.args = { + ...context.args, + target: '', + targetKind: '', + useRofi: true, + }; + const calls: string[] = []; + + await runPlaybackCommandWithDeps(context, { + ensurePlaybackSetupReady: async () => {}, + ensureRuntimePluginReady: async () => { + calls.push('assets'); + }, + chooseTarget: async () => { + calls.push('picker'); + return { target: '/tmp/movie.mkv', kind: 'file' }; + }, + checkPickerDependencies: () => {}, + checkDependencies: () => {}, + registerCleanup: () => {}, + startMpv: async () => { + calls.push('startMpv'); + }, + waitForUnixSocketReady: async () => true, + startOverlay: async () => {}, + launchAppCommandDetached: () => {}, + log: () => {}, + cleanupPlaybackSession: async () => {}, + getMpvProc: () => null, + }); + + assert.deepEqual(calls, ['assets', 'picker', 'startMpv']); +}); diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index 56a55c3c..78ae0b49 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -157,6 +157,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi }); }, chooseTarget, + checkPickerDependencies, checkDependencies, registerCleanup, startMpv, @@ -177,6 +178,7 @@ type PlaybackCommandDeps = { args: Args, scriptPath: string, ) => Promise<{ target: string; kind: 'file' | 'url' } | null>; + checkPickerDependencies?: (args: Args) => void; checkDependencies: (args: Args) => void; registerCleanup: (context: LauncherCommandContext) => void; startMpv: typeof startMpv; @@ -201,7 +203,18 @@ export async function runPlaybackCommandWithDeps( await deps.ensurePlaybackSetupReady(context); if (!args.target) { - checkPickerDependencies(args); + (deps.checkPickerDependencies ?? checkPickerDependencies)(args); + } + + let runtimeAssetsReady = false; + const ensureRuntimeAssetsReady = async (): Promise => { + if (runtimeAssetsReady) return; + await deps.ensureRuntimePluginReady(context); + runtimeAssetsReady = true; + }; + + if (!args.target && args.useRofi) { + await ensureRuntimeAssetsReady(); } const targetChoice = await deps.chooseTarget(args, scriptPath); @@ -266,7 +279,7 @@ export async function runPlaybackCommandWithDeps( ); } - await deps.ensureRuntimePluginReady(context); + await ensureRuntimeAssetsReady(); await deps.startMpv( selectedTarget.target, diff --git a/launcher/commands/update-command.test.ts b/launcher/commands/update-command.test.ts index 3766916e..da7ce1d6 100644 --- a/launcher/commands/update-command.test.ts +++ b/launcher/commands/update-command.test.ts @@ -36,6 +36,11 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as launcher: { status: 'updated' }, supportAssets: [ { status: 'updated', component: 'theme', message: 'Installed theme.' }, + { + status: 'updated', + component: 'thumbnailer', + message: 'Installed rofi thumbnailer.', + }, { status: 'skipped', component: 'plugin', message: 'Plugin already up to date.' }, ], }; @@ -52,6 +57,7 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as 'info:AppImage update: updated', 'info:Launcher update: updated', 'info:Support assets (theme) update: updated - Installed theme.', + 'info:Support assets (thumbnailer) update: updated - Installed rofi thumbnailer.', 'info:Support assets (plugin) update: skipped - Plugin already up to date.', ]); }); diff --git a/launcher/commands/update-command.ts b/launcher/commands/update-command.ts index e1050513..0b0a88be 100644 --- a/launcher/commands/update-command.ts +++ b/launcher/commands/update-command.ts @@ -21,7 +21,10 @@ import { parseSha256Sums, type FetchLike, } from '../../src/main/runtime/update/release-assets.js'; -import { updateSupportAssetsFromRelease } from '../../src/main/runtime/update/support-assets.js'; +import { + updateSupportAssetsFromRelease, + type SupportAssetsUpdateResult, +} from '../../src/main/runtime/update/support-assets.js'; type UpdateCommandResponse = { ok: boolean; @@ -36,15 +39,14 @@ type DirectReleaseUpdateRequest = { channel: UpdateChannel; }; +type DirectSupportAssetsUpdateResult = Omit & { + status: string; +}; + type DirectReleaseUpdateResult = { appImage: { status: string; command?: string; message?: string }; launcher: { status: string; command?: string; message?: string }; - supportAssets: Array<{ - status: string; - component?: 'theme' | 'plugin'; - command?: string; - message?: string; - }>; + supportAssets: DirectSupportAssetsUpdateResult[]; }; type UpdateCommandDeps = { @@ -129,12 +131,7 @@ function readUpdateChannel(root: Record | null): UpdateChannel function logUpdateResult( label: string, - result: { - status: string; - component?: 'theme' | 'plugin'; - command?: string; - message?: string; - }, + result: DirectSupportAssetsUpdateResult, configuredLogLevel: NonNullable, deps: Pick, ): void { diff --git a/launcher/main.test.ts b/launcher/main.test.ts index 96c938cd..463420f1 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -73,20 +73,21 @@ function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv }; } -// On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which — -// when the runtime plugin/theme are missing — spawns the app with -// `--ensure-linux-runtime-plugin-assets` and polls up to 30s +// On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which +// spawns the app with `--ensure-linux-runtime-plugin-assets` when managed +// support assets are missing and polls up to 30s // (RESPONSE_TIMEOUT_MS) for an install response. A fake app that just exits // never writes that response, so the launcher hangs and the test times out on // Linux CI (the preflight is a no-op on macOS/Windows). This shell prelude makes -// the fake app install the managed plugin/theme and write the response, matching +// the fake app install the managed support assets and write the response, matching // launcher/smoke.e2e.test.ts. Prepend it to each fake app that reaches playback. const RUNTIME_PLUGIN_PREFLIGHT_SH = `if [ "$1" = "--ensure-linux-runtime-plugin-assets" ]; then data="\${XDG_DATA_HOME:-$HOME/.local/share}/SubMiner" - mkdir -p "$data/plugin/subminer" "$data/themes" + mkdir -p "$data/plugin/subminer" "$data/themes" "$data/thumbnailers" printf -- '-- test plugin\\n' > "$data/plugin/subminer/main.lua" printf 'test=true\\n' > "$data/plugin/subminer.conf" printf '/* test theme */\\n' > "$data/themes/subminer.rasi" + printf '[Thumbnailer Entry]\\n' > "$data/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer" if [ "$2" = "--ensure-linux-runtime-plugin-assets-response-path" ] && [ -n "$3" ]; then mkdir -p "$(dirname "$3")" printf '{"ok":true,"status":"installed","path":"%s"}' "$data/plugin/subminer/main.lua" > "$3" diff --git a/launcher/picker.test.ts b/launcher/picker.test.ts index 281d1d1f..3a148d74 100644 --- a/launcher/picker.test.ts +++ b/launcher/picker.test.ts @@ -3,7 +3,12 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { findRofiTheme, formatRofiPrompt } from './picker'; +import { + findRofiTheme, + findRofiThumbnailerDataRoot, + formatRofiPrompt, + prependXdgDataDir, +} from './picker'; // ── formatRofiPrompt: spacing between prompt and input field ────────────────── @@ -23,6 +28,7 @@ test('formatRofiPrompt leaves an empty prompt empty', () => { // ── findRofiTheme: Linux packaged path discovery ────────────────────────────── const ROFI_THEME_FILE = 'subminer.rasi'; +const ROFI_THUMBNAILER_FILE = 'subminer-ffmpegthumbnailer.thumbnailer'; function makeFile(filePath: string): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); @@ -121,3 +127,42 @@ test('findRofiTheme resolves ~/.local/share/SubMiner/themes/subminer.rasi when X fs.rmSync(baseDir, { recursive: true, force: true }); } }); + +test('findRofiThumbnailerDataRoot resolves the managed XDG data root', () => { + const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-')); + const originalXdgDataHome = process.env.XDG_DATA_HOME; + try { + process.env.XDG_DATA_HOME = xdgDataHome; + const dataRoot = path.join(xdgDataHome, 'SubMiner'); + makeFile(path.join(dataRoot, 'thumbnailers', ROFI_THUMBNAILER_FILE)); + + const result = withPlatform('linux', () => findRofiThumbnailerDataRoot('/usr/bin/subminer')); + assert.equal(result, dataRoot); + } finally { + if (originalXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = originalXdgDataHome; + } + fs.rmSync(xdgDataHome, { recursive: true, force: true }); + } +}); + +test('findRofiThumbnailerDataRoot is Linux-only', () => { + assert.equal( + withPlatform('darwin', () => findRofiThumbnailerDataRoot('/usr/bin/subminer')), + null, + ); +}); + +test('prependXdgDataDir preserves existing roots and avoids duplicates', () => { + const root = '/tmp/subminer-data'; + assert.equal( + prependXdgDataDir(root, `/opt/share${path.delimiter}${root}${path.delimiter}/usr/share`), + `${root}${path.delimiter}/opt/share${path.delimiter}/usr/share`, + ); + assert.equal( + prependXdgDataDir(root), + `${root}${path.delimiter}/usr/local/share${path.delimiter}/usr/share`, + ); +}); diff --git a/launcher/picker.ts b/launcher/picker.ts index c35e3205..110a791a 100644 --- a/launcher/picker.ts +++ b/launcher/picker.ts @@ -159,6 +159,9 @@ interface RofiIconEntry { iconPath?: string; } +const ROFI_THUMBNAILER_FILE = 'subminer-ffmpegthumbnailer.thumbnailer'; +const DEFAULT_XDG_DATA_DIRS = ['/usr/local/share', '/usr/share']; + function showRofiIconMenu( entries: RofiIconEntry[], prompt: string, @@ -389,6 +392,47 @@ export function findRofiTheme(scriptPath: string): string | null { return null; } +export function findRofiThumbnailerDataRoot(scriptPath: string): string | null { + if (process.platform !== 'linux') return null; + + const scriptDir = path.dirname(realpathMaybe(scriptPath)); + const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local/share'); + const roots = [ + path.join(xdgDataHome, 'SubMiner'), + path.posix.join('/usr/local/share/SubMiner'), + path.posix.join('/usr/share/SubMiner'), + path.join(scriptDir, 'assets'), + path.join(scriptDir, '..', 'assets'), + ]; + + for (const root of roots) { + if (fs.existsSync(path.join(root, 'thumbnailers', ROFI_THUMBNAILER_FILE))) { + return root; + } + } + + return null; +} + +export function prependXdgDataDir(dataRoot: string, currentValue?: string): string { + const currentDirs = currentValue + ? currentValue.split(path.delimiter).filter(Boolean) + : DEFAULT_XDG_DATA_DIRS; + return [dataRoot, ...currentDirs.filter((candidate) => candidate !== dataRoot)].join( + path.delimiter, + ); +} + +function buildRofiThumbnailEnvironment(scriptPath: string): NodeJS.ProcessEnv { + if (!commandExists('ffmpegthumbnailer')) return process.env; + const dataRoot = findRofiThumbnailerDataRoot(scriptPath); + if (!dataRoot) return process.env; + return { + ...process.env, + XDG_DATA_DIRS: prependXdgDataDir(dataRoot, process.env.XDG_DATA_DIRS), + }; +} + export function showRofiMenu( videos: string[], dir: string, @@ -420,6 +464,7 @@ export function showRofiMenu( const result = spawnSync('rofi', args, { input: buildRofiMenu(videos, dir, recursive), encoding: 'utf8', + env: buildRofiThumbnailEnvironment(scriptPath), stdio: ['pipe', 'pipe', 'ignore'], }); if (result.error) { diff --git a/launcher/runtime-plugin-preflight.test.ts b/launcher/runtime-plugin-preflight.test.ts index e07dfe67..7d9be846 100644 --- a/launcher/runtime-plugin-preflight.test.ts +++ b/launcher/runtime-plugin-preflight.test.ts @@ -1,6 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { ensureLinuxRuntimePluginAvailable, installManagedPluginAssetsViaApp, @@ -31,7 +33,7 @@ test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', asyn assert.deepEqual(calls, []); }); -test('ensureLinuxRuntimePluginAvailable skips install when installed global plugin and managed theme exist', async () => { +test('ensureLinuxRuntimePluginAvailable skips install when plugin, theme, and thumbnailer exist', async () => { const calls: string[] = []; await ensureLinuxRuntimePluginAvailable({ @@ -52,13 +54,17 @@ test('ensureLinuxRuntimePluginAvailable skips install when installed global plug calls.push('theme'); return true; }, + isManagedThumbnailerAvailable: () => { + calls.push('thumbnailer'); + return true; + }, log: () => {}, }); - assert.deepEqual(calls, ['detect', 'theme']); + assert.deepEqual(calls, ['detect', 'theme', 'thumbnailer']); }); -test('ensureLinuxRuntimePluginAvailable skips install when managed runtime path and theme already resolve', async () => { +test('ensureLinuxRuntimePluginAvailable skips install when all managed assets resolve', async () => { const calls: string[] = []; await ensureLinuxRuntimePluginAvailable({ @@ -80,14 +86,19 @@ test('ensureLinuxRuntimePluginAvailable skips install when managed runtime path calls.push('theme'); return true; }, + isManagedThumbnailerAvailable: () => { + calls.push('thumbnailer'); + return true; + }, log: () => {}, }); - assert.deepEqual(calls, ['detect', 'resolve', 'theme']); + assert.deepEqual(calls, ['detect', 'resolve', 'theme', 'thumbnailer']); }); test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => { const calls: string[] = []; + let themeAvailable = false; await ensureLinuxRuntimePluginAvailable({ platform: 'linux', @@ -102,10 +113,15 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme }, isManagedThemeAvailable: () => { calls.push('theme'); - return false; + return themeAvailable; + }, + isManagedThumbnailerAvailable: () => { + calls.push('thumbnailer'); + return true; }, installManagedPluginAssets: async () => { calls.push('install'); + themeAvailable = true; return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' }; }, log: (level, _configured, message) => { @@ -117,13 +133,68 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme 'detect', 'resolve', 'theme', - 'info:Linux runtime support assets missing; installing managed plugin/theme assets.', + 'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.', 'install', - 'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi', + 'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer', 'resolve', + 'theme', + 'thumbnailer', ]); }); +test('ensureLinuxRuntimePluginAvailable installs managed assets when thumbnailer is missing', async () => { + const calls: string[] = []; + let thumbnailerAvailable = false; + + await ensureLinuxRuntimePluginAvailable({ + platform: 'linux', + xdgDataHome: '/tmp/xdg-data', + detectInstalledPlugin: () => true, + resolveRuntimePluginPath: () => '/tmp/plugin/main.lua', + isManagedThemeAvailable: () => true, + isManagedThumbnailerAvailable: () => thumbnailerAvailable, + installManagedPluginAssets: async () => { + calls.push('install'); + thumbnailerAvailable = true; + return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' }; + }, + log: (_level, _configured, message) => { + calls.push(message); + }, + }); + + assert.deepEqual(calls, [ + 'Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.', + 'install', + 'Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer', + ]); +}); + +test('ensureLinuxRuntimePluginAvailable retains an installed plugin after installing support assets', async () => { + const calls: string[] = []; + let thumbnailerAvailable = false; + + await ensureLinuxRuntimePluginAvailable({ + platform: 'linux', + xdgDataHome: '/tmp/xdg-data', + detectInstalledPlugin: () => true, + resolveRuntimePluginPath: () => { + calls.push('resolve'); + return null; + }, + isManagedThemeAvailable: () => true, + isManagedThumbnailerAvailable: () => thumbnailerAvailable, + installManagedPluginAssets: async () => { + calls.push('install'); + thumbnailerAvailable = true; + return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' }; + }, + log: () => {}, + }); + + assert.deepEqual(calls, ['install']); +}); + test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => { const calls: string[] = []; let resolveCount = 0; @@ -137,6 +208,8 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves calls.push(`resolve:${resolveCount}`); return resolveCount === 1 ? null : '/tmp/plugin/main.lua'; }, + isManagedThemeAvailable: () => true, + isManagedThumbnailerAvailable: () => true, installManagedPluginAssets: async () => { calls.push('install'); return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' }; @@ -148,9 +221,9 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves assert.deepEqual(calls, [ 'resolve:1', - 'info:Linux runtime support assets missing; installing managed plugin/theme assets.', + 'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.', 'install', - 'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi', + 'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer', 'resolve:2', ]); }); @@ -191,6 +264,60 @@ test('ensureLinuxRuntimePluginAvailable fails when runtime path remains unresolv ); }); +test('ensureLinuxRuntimePluginAvailable fails when thumbnailer remains missing after install', async () => { + await assert.rejects( + () => + ensureLinuxRuntimePluginAvailable({ + platform: 'linux', + xdgDataHome: '/tmp/xdg-data', + detectInstalledPlugin: () => true, + resolveRuntimePluginPath: () => '/tmp/plugin/main.lua', + isManagedThemeAvailable: () => true, + isManagedThumbnailerAvailable: () => false, + installManagedPluginAssets: async () => ({ + ok: true, + status: 'installed', + path: '/tmp/plugin/main.lua', + }), + log: () => {}, + }), + /thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i, + ); +}); + +test('ensureLinuxRuntimePluginAvailable rejects a thumbnailer directory before and after install', async () => { + const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-thumbnailer-directory-')); + const thumbnailerPath = path.join( + xdgDataHome, + 'SubMiner', + 'thumbnailers', + 'subminer-ffmpegthumbnailer.thumbnailer', + ); + fs.mkdirSync(thumbnailerPath, { recursive: true }); + const calls: string[] = []; + + try { + await assert.rejects( + () => + ensureLinuxRuntimePluginAvailable({ + platform: 'linux', + xdgDataHome, + detectInstalledPlugin: () => true, + isManagedThemeAvailable: () => true, + installManagedPluginAssets: async () => { + calls.push('install'); + return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' }; + }, + log: () => {}, + }), + /thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i, + ); + assert.deepEqual(calls, ['install']); + } finally { + fs.rmSync(xdgDataHome, { recursive: true, force: true }); + } +}); + test('installManagedPluginAssetsViaApp returns launch errors without waiting for a response file', async () => { let waited = false; diff --git a/launcher/runtime-plugin-preflight.ts b/launcher/runtime-plugin-preflight.ts index b794cd6b..e1771a3e 100644 --- a/launcher/runtime-plugin-preflight.ts +++ b/launcher/runtime-plugin-preflight.ts @@ -31,6 +31,7 @@ type EnsureLinuxRuntimePluginAvailableOptions = { detectInstalledPlugin?: () => boolean; resolveRuntimePluginPath?: () => string | null; isManagedThemeAvailable?: () => boolean; + isManagedThumbnailerAvailable?: () => boolean; installManagedPluginAssets?: () => Promise; log?: PreflightLog; }; @@ -48,6 +49,14 @@ function resolveConfiguredLogLevel( return logLevel ?? 'warn'; } +function isRegularFile(filePath: string): boolean { + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } +} + async function waitForInstallResponse( responsePath: string, ): Promise { @@ -170,15 +179,17 @@ export async function ensureLinuxRuntimePluginAvailable( }); const isManagedThemeAvailable = options.isManagedThemeAvailable ?? (() => fs.existsSync(managedPaths.themePath)); + const isManagedThumbnailerAvailable = + options.isManagedThumbnailerAvailable ?? (() => isRegularFile(managedPaths.thumbnailerPath)); const runtimePluginAvailable = installedPluginAvailable || Boolean(resolveRuntimePluginPath()); - if (runtimePluginAvailable && isManagedThemeAvailable()) { + if (runtimePluginAvailable && isManagedThemeAvailable() && isManagedThumbnailerAvailable()) { return; } log( 'info', configuredLogLevel, - 'Linux runtime support assets missing; installing managed plugin/theme assets.', + 'Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.', ); const installManagedPluginAssets = options.installManagedPluginAssets ?? @@ -207,16 +218,21 @@ export async function ensureLinuxRuntimePluginAvailable( log( 'info', configuredLogLevel, - `Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath}`, + `Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath} thumbnailer=${managedPaths.thumbnailerPath}`, ); - const runtimePluginPath = resolveRuntimePluginPath(); - if (runtimePluginPath) { + const runtimePluginAvailableAfterInstall = + installedPluginAvailable || Boolean(resolveRuntimePluginPath()); + if ( + runtimePluginAvailableAfterInstall && + isManagedThemeAvailable() && + isManagedThumbnailerAvailable() + ) { return; } const message = `Linux managed runtime plugin assets could not be installed. ` + - `Checked path: ${managedPaths.pluginEntrypointPath}. ` + + `Checked paths: plugin=${managedPaths.pluginEntrypointPath} theme=${managedPaths.themePath} thumbnailer=${managedPaths.thumbnailerPath}. ` + 'Launch aborted before starting mpv.'; log('warn', configuredLogLevel, message); throw new Error(message); diff --git a/launcher/smoke.e2e.test.ts b/launcher/smoke.e2e.test.ts index d44a07c4..718622f8 100644 --- a/launcher/smoke.e2e.test.ts +++ b/launcher/smoke.e2e.test.ts @@ -165,11 +165,14 @@ if (entry.argv.includes('--ensure-linux-runtime-plugin-assets')) { const pluginDir = path.join(dataDir, 'plugin', 'subminer'); const pluginConfigPath = path.join(dataDir, 'plugin', 'subminer.conf'); const themePath = path.join(dataDir, 'themes', 'subminer.rasi'); + const thumbnailerPath = path.join(dataDir, 'thumbnailers', 'subminer-ffmpegthumbnailer.thumbnailer'); fs.mkdirSync(pluginDir, { recursive: true }); fs.mkdirSync(path.dirname(themePath), { recursive: true }); + fs.mkdirSync(path.dirname(thumbnailerPath), { recursive: true }); fs.writeFileSync(path.join(pluginDir, 'main.lua'), '-- smoke plugin\\n'); fs.writeFileSync(pluginConfigPath, 'smoke=true\\n'); fs.writeFileSync(themePath, '/* smoke theme */\\n'); + fs.writeFileSync(thumbnailerPath, '[Thumbnailer Entry]\\n'); if (responsePath) { fs.mkdirSync(path.dirname(responsePath), { recursive: true }); fs.writeFileSync(responsePath, JSON.stringify({ ok: true, status: 'installed', path: path.join(pluginDir, 'main.lua') })); @@ -620,11 +623,22 @@ test( ); assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i); if (process.platform === 'linux') { - assert.match(result.stdout, /managed plugin\/theme assets/i); + assert.match(result.stdout, /managed plugin\/theme\/thumbnailer assets/i); assert.equal( fs.existsSync(path.join(smokeCase.xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi')), true, ); + assert.equal( + fs.existsSync( + path.join( + smokeCase.xdgDataHome, + 'SubMiner', + 'thumbnailers', + 'subminer-ffmpegthumbnailer.thumbnailer', + ), + ), + true, + ); } }); }, diff --git a/launcher/test-support/immersion-db-fixture.ts b/launcher/test-support/immersion-db-fixture.ts index 3edbaa14..81d457a8 100644 --- a/launcher/test-support/immersion-db-fixture.ts +++ b/launcher/test-support/immersion-db-fixture.ts @@ -18,6 +18,9 @@ export function createImmersionDbFixture(dbPath: string): void { db.prepare( `INSERT INTO imm_rollup_state(state_key, state_value) VALUES ('last_rollup_sample_ms', 0)`, ).run(); + db.prepare( + `INSERT INTO imm_rollup_state(state_key, state_value) VALUES ('lexical_daily_rollups_version', 0)`, + ).run(); db.prepare( `INSERT INTO imm_lifetime_global(global_id, CREATED_DATE, LAST_UPDATE_DATE) VALUES (1, ?, ?)`, ).run(String(Date.now()), String(Date.now())); diff --git a/launcher/test-support/immersion-db-schema.test.ts b/launcher/test-support/immersion-db-schema.test.ts index f4f1d24e..0320a7c9 100644 --- a/launcher/test-support/immersion-db-schema.test.ts +++ b/launcher/test-support/immersion-db-schema.test.ts @@ -108,6 +108,36 @@ test('fixture schema stays aligned with production sync-touched tables and index } }); +test('fixture leaves lexical rollups pending when their table is absent', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-fixture-rollup-state-')); + const fixturePath = path.join(dir, 'fixture.sqlite'); + try { + createImmersionDbFixture(fixturePath); + const db = new BunDatabase(fixturePath, { readonly: true }); + try { + const state = db + .query<{ state_value: string }>( + `SELECT state_value FROM imm_rollup_state + WHERE state_key = 'lexical_daily_rollups_version'`, + ) + .get(); + const rollupTable = db + .query<{ name: string }>( + `SELECT name FROM sqlite_schema + WHERE type = 'table' AND name = 'imm_lexical_daily_rollups'`, + ) + .get(); + + assert.equal(state?.state_value, '0'); + assert.equal(rollupTable, null); + } finally { + db.close(); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('fixture session inserts enforce foreign keys', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-fixture-foreign-keys-')); const fixturePath = path.join(dir, 'fixture.sqlite'); diff --git a/launcher/test-support/immersion-db-schema.ts b/launcher/test-support/immersion-db-schema.ts index 11977633..c99e2cf4 100644 --- a/launcher/test-support/immersion-db-schema.ts +++ b/launcher/test-support/immersion-db-schema.ts @@ -154,6 +154,7 @@ export const IMMERSION_DB_FIXTURE_DDL = ` last_seen REAL, frequency INTEGER, frequency_rank INTEGER, + vocabulary_visible INTEGER NOT NULL DEFAULT 1 CHECK(vocabulary_visible IN (0, 1)), UNIQUE(headword, word, reading) ); CREATE TABLE imm_kanji( diff --git a/package.json b/package.json index 8ee52777..83bd9e82 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "subminer", "productName": "SubMiner", "desktopName": "SubMiner.desktop", - "version": "0.19.4-beta.1", + "version": "0.19.5", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "packageManager": "bun@1.3.5", "main": "dist/main-entry.js", @@ -32,6 +32,7 @@ "changelog:pr-check": "bun run scripts/build-changelog.ts pr-check", "changelog:release-notes": "bun run scripts/build-changelog.ts release-notes", "changelog:prerelease-notes": "bun run scripts/build-changelog.ts prerelease-notes", + "changelog:check-prerelease-notes": "bun run scripts/build-changelog.ts check-prerelease-notes", "format": "prettier --write .", "format:check": "prettier --check .", "format:src": "bash scripts/prettier-scope.sh --write", diff --git a/packaging/aur/subminer-bin/PKGBUILD b/packaging/aur/subminer-bin/PKGBUILD index c01e73ab..cbe87dd1 100644 --- a/packaging/aur/subminer-bin/PKGBUILD +++ b/packaging/aur/subminer-bin/PKGBUILD @@ -58,6 +58,8 @@ package() { "${pkgdir}/usr/share/SubMiner/plugin/subminer.conf" install -Dm644 "${srcdir}/assets/themes/subminer.rasi" \ "${pkgdir}/usr/share/SubMiner/themes/subminer.rasi" + install -Dm644 "${srcdir}/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer" \ + "${pkgdir}/usr/share/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer" install -dm755 "${pkgdir}/usr/share/SubMiner/plugin/subminer" cp -a "${srcdir}/plugin/subminer/." "${pkgdir}/usr/share/SubMiner/plugin/subminer/" diff --git a/release/prerelease-notes.md b/release/prerelease-notes.md index 38af51ad..ae7c87f0 100644 --- a/release/prerelease-notes.md +++ b/release/prerelease-notes.md @@ -4,37 +4,46 @@ ## Highlights ### Added -- **Library Merge and Move** - - Duplicate library cards for the same show can now be combined: select cards in the library grid, choose "Merge Selected," and pick which entry to keep. Sessions, mined cards, and watch time move over, and future episodes stay matched to the merged card. - - Episodes can be reassigned to a different library entry with a "→" button on the episode row, fixing cases where a file lands under the wrong title. Manual assignments survive later filename parsing, Jellyfin refreshes, and season repair. - - Exact AniList matches with compatible seasons now merge automatically, while fuzzy matches show up as a dismissible "Possible duplicate" prompt instead of merging without confirmation. + +- Library Merge & Reassignment + - Duplicate library cards for the same show can be combined: select entries in "Select" mode and use "Merge Selected" to combine their sessions, mined cards, and watch time onto one card. + - Episodes can be moved to a different entry with a per-episode "→" button, fixing stray files that split off their own entry; manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair. + - Exact AniList matches with compatible seasons merge automatically, while likely (fuzzy) matches surface as a dismissible "Possible duplicate" suggestion instead of merging silently. + +- Duplicate Line Cleanup + - The Vocabulary tab's new **Duplicates** button scans a chosen time window for the repeated-line bursts described under Fixed below and collapses each burst to a single line once confirmed. + - A matching `subminer stats cleanup --duplicate-lines` command (with `--dry-run` and `--lookback-days `) is available from the terminal. + - Only the affected subtitle lines and the vocabulary counts they inflated are touched; watch time and lines-seen totals are left as recorded. ### Fixed -- **Anki Audio Generation on Network Drives** - - Fixed sentence-audio generation timing out on slow network-mounted video files with many subtitle and font-attachment streams. - - Extraction now uses bounded probing and a two-minute budget, and failures show a clear error instead of a cryptic one. -- **Duplicate Subtitle Line Stats** - - Fixed karaoke openings and animated signs (which record one subtitle event per animation frame) inflating word and kanji counts and skewing "Top Repeated Words." Ordinary repeated dialogue and rewatches are unaffected. - - Already-inflated stats can be cleaned up with the new "Duplicates" button in the Vocabulary tab, or `subminer stats cleanup --duplicate-lines` (supports `--dry-run` and `--lookback-days`). Only the affected subtitle lines and vocabulary counts are touched; watch time and lines-seen totals are untouched. -- **Overlay Modals on macOS and Windows** - - Fixed overlay modals and the stats window opening on the wrong macOS Space, or forcing a Space switch, when mpv is fullscreen. They now open above fullscreen mpv on its current Space. - - Modals are now prewarmed on macOS and Windows so shortcuts open them promptly, and Windows keeps the hidden modal responsive between sessions. -- **Wayland File Drag-and-Drop** - - Fixed dragging subtitle and video files from file managers like Thunar onto the overlay on native Wayland; dropped files are now resolved and sent to mpv. -- **Windows Mouse Lag** - - Fixed system-wide mouse lag while SubMiner is running on Windows, caused by a global mouse hook and blocking window lookups during click-through tracking. -- **Mining Clip Accuracy** - - Fixed mined audio and animated image clips sometimes capturing the wrong subtitle line when audio extraction was slow. The clip range is now locked in at the moment of lookup, so audio and image clips always match. -- **Linux Notifications** - - Character dictionary progress notifications on Linux now update in place instead of flickering off and back on with every status change. -- **Stats Delete Performance** - - Fixed stats deletes freezing the dashboard; deletes now reliably run off the main thread, with automatic retry if the delete worker crashes. - - Deletes, library merges/moves, and AniList reassignments are now much faster because totals are updated incrementally instead of rebuilt from scratch, and no longer erase lifetime totals older than the recent session history. - - Session deletes on large libraries dropped from minutes to milliseconds. -### Docs -- **Feature Demos Page** - - Hidden the unfinished feature demos page from the documentation sidebar; it's still reachable by direct URL. +- Subtitle Duplication from Karaoke & Animated Signs + - Typeset ASS karaoke and animated signs no longer flood the overlay, subtitle sidebar, immersion history, mined cards, or stats with repeated glyph fragments or per-frame duplicates; the complete authored line is recovered instead, without merging genuinely repeated dialogue or separately positioned signs. + - Fragmented karaoke now preserves the spaces the author placed between words instead of joining them together, and lyric transitions (including seeking into the middle of a line) resolve to the clean line instead of a stray entrance or exit frame. + - The secondary overlay shares the same deduplication logic as the primary overlay, including collapsing lines that differ only by whitespace or trailing punctuation, and sidebar navigation moves between clean lyric lines while keeping the right line selected. + +- Anki Media Generation + - Sentence-audio generation no longer times out on slow network-mounted video files with many subtitle and font streams, and a failed extraction now reports a clear error instead of a raw `ENOENT`. + - Mined audio and animated AVIF clips now capture the subtitle line you actually mined, instead of whatever line happened to be on screen once slow audio extraction finished. + +- Character Dictionary Performance & Notifications + - Character dictionary generation, merged rebuilds, and imports no longer freeze the app on large dictionaries, and cached results (including character portraits) are reused across launches instead of regenerating everything every time. + - Portraits also now display correctly if their cache finishes loading after subtitles have already started showing. + - Desktop progress notifications, including on Linux AppImage installs, now update in place instead of flickering closed and reopening. + +- Overlay Reliability + - Overlay modals (settings, stats, etc.) now open promptly on the first shortcut press, including on repeated sessions on Windows, and appear above fullscreen mpv on macOS instead of switching Spaces or opening off-screen. + - The macOS window-tracking helper is now built for macOS 12.0+, so the overlay attaches to mpv on older systems like Ventura instead of crashing and getting stuck on "Overlay loading." + - The overlay no longer gets stuck on "Overlay loading" indefinitely if mpv's connection stalls; it now retries and shows an actionable error after 30 seconds. + - Fixed native Wayland drag-and-drop from file managers like Thunar, and fixed system-wide mouse lag on Windows caused by the overlay's click-through handling. + +- Stats Dashboard + - Deletes, library merges, video moves, and AniList reassignments no longer freeze the stats dashboard or rebuild lifetime totals from scratch; large deletes that used to take minutes now finish in milliseconds. + - Vocabulary totals and charts now count all tracked vocabulary instead of just the first page, and new-word history uses corrected daily rollups. + - Calendar labels respect time zones west of UTC, and vocabulary cards refresh automatically after editing the word exclusion list (with a Retry option if a load fails). + +- Linux Launcher Thumbnails + - Fixed missing MKV thumbnails in the Linux rofi picker when the system thumbnailer only registers legacy Matroska MIME aliases. ## What's Changed @@ -47,6 +56,13 @@ - fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199 - fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200 - fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201 +- fix(stats): report complete vocabulary totals and new-word history by @ksyasuda in #202 +- fix(mpv): recover from stalled IPC connects by @ksyasuda in #204 +- fix(dictionary): prevent freezes and restore AppImage notifications by @ksyasuda in #205 +- fix(subtitles): recover canonical lines from ASS animation by @ksyasuda in #207 +- fix(overlay): deduplicate secondary subtitle rendering by @ksyasuda in #208 +- fix(launcher): restore Matroska thumbnails in Linux rofi picker by @ksyasuda in #210 +- fix(character-dictionary): cache completed MeCab refreshes by @ksyasuda in #212 ## Installation diff --git a/release/release-notes.md b/release/release-notes.md new file mode 100644 index 00000000..476c69fc --- /dev/null +++ b/release/release-notes.md @@ -0,0 +1,33 @@ +## Highlights +### Fixed + +- **Anki Card Update Progress**: The update spinner now stays visible until audio and image updates actually finish, so you won't mistake an in-progress update for a failure. +- **Word-Card Field Enrichment**: Word-card enrichment now reliably writes sentence text and audio into whichever AnkiConnect fields you've configured, while the dedicated Lapis/Kiku sentence-card and audio-card actions still use their expected field names. +- **Overlapping Subtitles**: + - Lines that start while another is still on screen now show together instead of staying hidden until you switch tracks or seek. + - Subtitles shown at the same time now stack by their authored screen position, with signs and song lyrics above dialogue. + - Half-size ASS furigana no longer shows up as if it were its own subtitle line. +- **YouTube Auto-Generated Captions**: + - Captions now follow their intended timing instead of drifting off sync. + - Long speech is paged across two rows instead of piling into a wall of text. + - Timed sound cues like `[音楽]` no longer linger over later dialogue. + +## What's Changed + +- fix(anki): keep overlay progress visible through card updates by @ksyasuda in #218 +- fix(youtube): keep auto captions on screen for their full span by @ksyasuda in #219 +- fix(subtitles): keep overlapping lines that join an already active cue by @ksyasuda in #221 +- fix(anki): respect configured fields for word-card enrichment by @ksyasuda in #223 + +## Installation + +See the README and docs/installation guide for full setup steps. + +## Assets + +- Linux: `SubMiner.AppImage` +- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip` +- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip` +- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher + +Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`. diff --git a/scripts/build-changelog.test.ts b/scripts/build-changelog.test.ts index 807672f7..764bbffd 100644 --- a/scripts/build-changelog.test.ts +++ b/scripts/build-changelog.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; @@ -43,14 +44,22 @@ function fragmentTypesInPrompt(input: string): string[] { .map((line) => line.slice('type: '.length).trim()); } -function assertReleaseNotesPromptRequestsNestedBullets(input: string): void { - assert.match(input, /In MODE: release-notes, use short top-level change bullets/); - assert.match(input, /Nested bullets should cover the change, user benefit, and any user action/); - assert.match(input, /Do not require the exact nested labels/); +function assertPromptRequestsNestedBullets(input: string): void { + assert.match(input, /In both modes, split every item into one nested bullet per distinct change/); + assert.match(input, /Never stack several distinct changes into one long paragraph-shaped bullet/); assert.match(input, /Keep nested bullets short, concrete, and readable by non-technical users/); assert.match(input, /Avoid paragraph-style release-note bullets/); } +function assertReleaseNotesPromptRequestsNestedBullets(input: string): void { + assertPromptRequestsNestedBullets(input); + assert.match( + input, + /In MODE: release-notes, nested bullets should also cover user benefit and any user action/, + ); + assert.match(input, /Do not require the exact nested labels/); +} + function defaultPolishedBody(input: string): string { const mode = modeFromPrompt(input); const types = fragmentTypesInPrompt(input); @@ -445,6 +454,7 @@ test('writeChangelogArtifacts prompts Claude to summarize the final stable outco prompt, /Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/, ); + assertPromptRequestsNestedBullets(prompt); } const releaseNotesPrompt = stub.calls.find( @@ -583,7 +593,7 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati const outputPath = writePrereleaseNotesForVersion({ cwd: projectRoot, version: '0.11.3-beta.1', - deps: { runClaude: stub.runClaude }, + deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] }, }); assert.equal(outputPath, path.join(projectRoot, 'release', 'prerelease-notes.md')); @@ -605,7 +615,8 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m); - assert.match(prereleaseNotes, //); + assert.match(prereleaseNotes, //); + assert.doesNotMatch(prereleaseNotes, /## Changes since /); assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./); assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./); assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/); @@ -668,7 +679,7 @@ test('writePrereleaseNotesForVersion reuses existing prerelease notes when addin const outputPath = writePrereleaseNotesForVersion({ cwd: projectRoot, version: '0.11.3-beta.2', - deps: { runClaude: stub.runClaude }, + deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] }, }); assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call'); @@ -723,7 +734,7 @@ test('writePrereleaseNotesForVersion ignores unmarked prerelease notes from an o const outputPath = writePrereleaseNotesForVersion({ cwd: projectRoot, version: '0.17.0-beta.1', - deps: { runClaude: stub.runClaude }, + deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] }, }); assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call'); @@ -790,7 +801,7 @@ test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease b writePrereleaseNotesForVersion({ cwd: projectRoot, version: '0.12.0-beta.2', - deps: { runClaude: stub.runClaude }, + deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] }, }); assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call'); @@ -830,7 +841,7 @@ test('writePrereleaseNotesForVersion supports rc prereleases', async () => { const outputPath = writePrereleaseNotesForVersion({ cwd: projectRoot, version: '0.11.3-rc.1', - deps: { runClaude: stub.runClaude }, + deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] }, }); const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); @@ -1447,3 +1458,373 @@ test('writeChangelogArtifacts strips
blocks from release notes when re fs.rmSync(workspace, { recursive: true, force: true }); } }); + +test('selectPreviousPrereleaseTag orders betas before rcs and filters other base versions', async () => { + const { selectPreviousPrereleaseTag } = await loadModule(); + + const tags = [ + 'v0.19.4-beta.1', + 'v0.19.4-beta.3', + 'v0.19.4-beta.2', + 'v0.19.3-beta.9', + 'v0.19.4-rc.1', + 'not-a-tag', + ]; + + assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-beta.1'), null); + assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-beta.2'), 'v0.19.4-beta.1'); + assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-beta.4'), 'v0.19.4-beta.3'); + assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-rc.1'), 'v0.19.4-beta.3'); + assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-rc.2'), 'v0.19.4-rc.1'); + // Regenerating notes for an already-tagged version must not pick itself. + assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-beta.3'), 'v0.19.4-beta.2'); + assert.equal(selectPreviousPrereleaseTag(['v0.19.3-beta.1'], '0.19.4-beta.2'), null); +}); + +test('writePrereleaseNotesForVersion adds a delta section generated from fragment diffs', async () => { + const { writePrereleaseNotesForVersion } = await loadModule(); + const workspace = createWorkspace('prerelease-delta-section'); + const projectRoot = path.join(workspace, 'SubMiner'); + + fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2), + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'changes', '001.md'), + ['type: fixed', 'area: overlay', '', '- Fixed overlay focus and macOS helper.'].join('\n'), + 'utf8', + ); + + try { + const stub = recordingRunClaude((input) => + input.includes('MODIFIED FRAGMENT') + ? '- Fixed the macOS helper deployment target for older systems.' + : '### Fixed\n- Overlay: cumulative fixed entry.', + ); + const outputPath = writePrereleaseNotesForVersion({ + cwd: projectRoot, + version: '0.12.0-beta.2', + deps: { + runClaude: stub.runClaude, + listPrereleaseTags: () => ['v0.12.0-beta.1'], + resolveFragmentDelta: (_cwd, previousTag) => { + assert.equal(previousTag, 'v0.12.0-beta.1'); + return [ + { + path: 'changes/002.md', + status: 'added', + after: 'type: fixed\narea: macos\n\n- Fixed helper deployment target.', + }, + { + path: 'changes/001.md', + status: 'modified', + before: '- Fixed overlay focus.', + after: '- Fixed overlay focus and macOS helper.', + }, + { + path: 'changes/003.md', + status: 'deleted', + before: 'type: added\narea: stats\n\n- Reverted experimental stats view.', + }, + ]; + }, + }, + }); + + assert.equal(stub.calls.length, 2, 'delta and cumulative polish are separate Claude calls'); + const deltaPrompt = stub.calls[0]!.input; + assert.match(deltaPrompt, /ADDED FRAGMENT changes\/002\.md/); + assert.match(deltaPrompt, /MODIFIED FRAGMENT changes\/001\.md/); + assert.match(deltaPrompt, /BEFORE:\n- Fixed overlay focus\./); + assert.match(deltaPrompt, /AFTER:\n- Fixed overlay focus and macOS helper\./); + assert.match(deltaPrompt, /DELETED FRAGMENT changes\/003\.md/); + assert.match(deltaPrompt, /If the edit is editorial/); + assert.match(deltaPrompt, /removed or reverted/); + assert.match(deltaPrompt, /No user-facing changes since v0\.12\.0-beta\.1\./); + assert.equal(modeFromPrompt(stub.calls[1]!.input), 'release-notes'); + + const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); + assert.match( + prereleaseNotes, + //, + ); + const deltaIndex = prereleaseNotes.indexOf('## Changes since v0.12.0-beta.1'); + const highlightsIndex = prereleaseNotes.indexOf('## Highlights'); + assert.ok(deltaIndex !== -1, 'delta section heading should be present'); + assert.ok(deltaIndex < highlightsIndex, 'delta section should precede Highlights'); + assert.match(prereleaseNotes, /- Fixed the macOS helper deployment target for older systems\./); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('writePrereleaseNotesForVersion renders a fallback delta line when no fragments changed', async () => { + const { writePrereleaseNotesForVersion } = await loadModule(); + const workspace = createWorkspace('prerelease-empty-delta'); + const projectRoot = path.join(workspace, 'SubMiner'); + + fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'subminer', version: '0.12.0-beta.3' }, null, 2), + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'changes', '001.md'), + ['type: fixed', 'area: overlay', '', '- Fixed overlay focus.'].join('\n'), + 'utf8', + ); + + try { + const stub = defaultStubClaude(); + const outputPath = writePrereleaseNotesForVersion({ + cwd: projectRoot, + version: '0.12.0-beta.3', + deps: { + runClaude: stub.runClaude, + listPrereleaseTags: () => ['v0.12.0-beta.1', 'v0.12.0-beta.2'], + resolveFragmentDelta: () => [], + }, + }); + + assert.equal(stub.calls.length, 1, 'empty delta must not spend a Claude call'); + const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); + assert.match( + prereleaseNotes, + /## Changes since v0\.12\.0-beta\.2\n\n- No changelog fragment changes since v0\.12\.0-beta\.2; this build contains packaging or internal-only updates\./, + ); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('writePrereleaseNotesForVersion rejects non-bullet delta output from Claude', async () => { + const { writePrereleaseNotesForVersion } = await loadModule(); + const workspace = createWorkspace('prerelease-delta-invalid-output'); + const projectRoot = path.join(workspace, 'SubMiner'); + + fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2), + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'changes', '001.md'), + ['type: fixed', 'area: overlay', '', '- Fixed overlay focus.'].join('\n'), + 'utf8', + ); + + try { + const stub = recordingRunClaude(() => 'Here are the changes:\n- One change.'); + assert.throws( + () => + writePrereleaseNotesForVersion({ + cwd: projectRoot, + version: '0.12.0-beta.2', + deps: { + runClaude: stub.runClaude, + listPrereleaseTags: () => ['v0.12.0-beta.1'], + resolveFragmentDelta: () => [ + { path: 'changes/001.md', status: 'added', after: '- Fixed overlay focus.' }, + ], + }, + }), + /delta output must contain only Markdown bullets/, + ); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('writePrereleaseNotesForVersion strips the stale delta section from the reused baseline', async () => { + const { writePrereleaseNotesForVersion } = await loadModule(); + const workspace = createWorkspace('prerelease-reuse-strips-delta'); + const projectRoot = path.join(workspace, 'SubMiner'); + const existingNotes = [ + '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.', + '', + '', + '', + '## Changes since v0.12.0-beta.1', + '', + '- Stale beta-to-beta delta bullet.', + '', + '## Highlights', + '### Added', + '- Overlay: Previous beta entry.', + '', + '## Installation', + '', + 'See the README and docs/installation guide for full setup steps.', + '', + ].join('\n'); + + fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'subminer', version: '0.12.0-beta.3' }, null, 2), + 'utf8', + ); + fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8'); + fs.writeFileSync( + path.join(projectRoot, 'changes', '001.md'), + ['type: added', 'area: overlay', '', '- Added overlay coverage.'].join('\n'), + 'utf8', + ); + + try { + const stub = defaultStubClaude(); + writePrereleaseNotesForVersion({ + cwd: projectRoot, + version: '0.12.0-beta.3', + deps: { + runClaude: stub.runClaude, + listPrereleaseTags: () => [], + resolveFragmentDelta: () => [], + }, + }); + + assert.equal(stub.calls.length, 1); + const prompt = stub.calls[0]!.input; + assert.match(prompt, /EXISTING PRERELEASE NOTES/); + assert.match(prompt, /Overlay: Previous beta entry\./); + assert.doesNotMatch(prompt, /Stale beta-to-beta delta bullet\./); + assert.doesNotMatch(prompt, /## Changes since /); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('verifyPrereleaseNotesMatchVersion accepts matching notes and rejects stale or legacy markers', async () => { + const { verifyPrereleaseNotesMatchVersion } = await loadModule(); + const workspace = createWorkspace('verify-prerelease-notes'); + const projectRoot = path.join(workspace, 'SubMiner'); + const notesPath = path.join(projectRoot, 'release', 'prerelease-notes.md'); + + fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2), + 'utf8', + ); + + try { + assert.throws( + () => verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' }), + /Missing .*prerelease-notes\.md/, + ); + + fs.writeFileSync( + notesPath, + '\n\n## Highlights\n', + 'utf8', + ); + verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' }); + verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: 'v0.12.0-beta.2' }); + + fs.writeFileSync( + notesPath, + '\n\n## Highlights\n', + 'utf8', + ); + assert.throws( + () => verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' }), + /generated for 0\.12\.0-beta\.1 but this release is 0\.12\.0-beta\.2/, + ); + + fs.writeFileSync( + notesPath, + '\n\n## Highlights\n', + 'utf8', + ); + assert.throws( + () => verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' }), + /missing or legacy prerelease-version marker/, + ); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('default git tag listing and fragment delta resolution work against a real repository', async () => { + const { writePrereleaseNotesForVersion } = await loadModule(); + const workspace = createWorkspace('prerelease-git-defaults'); + const projectRoot = path.join(workspace, 'SubMiner'); + const git = (...args: string[]): void => { + execFileSync('git', args, { cwd: projectRoot, stdio: 'ignore' }); + }; + + fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2), + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'changes', 'kept.md'), + ['type: added', 'area: overlay', '', '- Kept change.'].join('\n'), + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'changes', 'edited.md'), + ['type: fixed', 'area: launcher', '', '- Original launcher fix.'].join('\n'), + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'changes', 'removed.md'), + ['type: added', 'area: stats', '', '- Reverted stats change.'].join('\n'), + 'utf8', + ); + + try { + git('init', '--quiet'); + git('-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'add', '.'); + git('-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'beta.1'); + git('tag', 'v0.11.3-beta.1'); + + fs.writeFileSync( + path.join(projectRoot, 'changes', 'edited.md'), + ['type: fixed', 'area: launcher', '', '- Broader launcher fix.'].join('\n'), + 'utf8', + ); + fs.rmSync(path.join(projectRoot, 'changes', 'removed.md')); + fs.writeFileSync( + path.join(projectRoot, 'changes', 'new.md'), + ['type: added', 'area: anki', '', '- New anki change.'].join('\n'), + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'subminer', version: '0.11.3-beta.2' }, null, 2), + 'utf8', + ); + + const stub = recordingRunClaude((input) => + input.includes('PREVIOUS_TAG:') ? '- Delta bullet.' : defaultPolishedBody(input), + ); + writePrereleaseNotesForVersion({ + cwd: projectRoot, + version: '0.11.3-beta.2', + deps: { runClaude: stub.runClaude }, + }); + + assert.equal(stub.calls.length, 2); + const deltaPrompt = stub.calls[0]!.input; + assert.match(deltaPrompt, /PREVIOUS_TAG: v0\.11\.3-beta\.1/); + assert.match(deltaPrompt, /ADDED FRAGMENT changes\/new\.md/); + assert.match(deltaPrompt, /- New anki change\./); + assert.match(deltaPrompt, /MODIFIED FRAGMENT changes\/edited\.md/); + assert.match(deltaPrompt, /- Original launcher fix\./); + assert.match(deltaPrompt, /- Broader launcher fix\./); + assert.match(deltaPrompt, /DELETED FRAGMENT changes\/removed\.md/); + assert.match(deltaPrompt, /- Reverted stats change\./); + assert.doesNotMatch(deltaPrompt, /kept\.md/); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); diff --git a/scripts/build-changelog.ts b/scripts/build-changelog.ts index df6ca317..2330e26d 100644 --- a/scripts/build-changelog.ts +++ b/scripts/build-changelog.ts @@ -18,6 +18,15 @@ type Contribution = { // and the GitHub API. type ResolveContributions = (fragmentPaths: string[], cwd: string) => Contribution[]; +// One changelog fragment's change between the previous prerelease tag and the +// working tree. `before` is the content at the tag, `after` the current content. +export type FragmentDeltaEntry = { + path: string; + status: 'added' | 'modified' | 'deleted'; + before?: string; + after?: string; +}; + type ChangelogFsDeps = { existsSync?: (candidate: string) => boolean; mkdirSync?: (candidate: string, options: { recursive: true }) => void; @@ -28,6 +37,8 @@ type ChangelogFsDeps = { log?: (message: string) => void; runClaude?: RunClaude; resolveContributions?: ResolveContributions; + listPrereleaseTags?: (cwd: string, baseVersion: string) => string[]; + resolveFragmentDelta?: (cwd: string, previousTag: string) => FragmentDeltaEntry[]; }; type PolishMode = 'changelog' | 'release-notes'; @@ -103,16 +114,57 @@ function resolvePrereleaseBaseVersion(version: string): string { return match[1]!; } -function renderPrereleaseBaseVersionMarker(version: string): string { - return ``; +// The marker records which exact prerelease the committed notes were generated +// for (and which prior tag the delta section compares against), so CI can +// reject notes that were prepared for a different beta/RC. +function renderPrereleaseVersionMarker(version: string, previousTag: string | null): string { + const since = previousTag ? `; since: ${previousTag}` : ''; + return ``; } +export function extractPrereleaseVersionMarker(notes: string): string | null { + return ( + //u.exec( + notes, + )?.[1] ?? null + ); +} + +// Legacy marker written before the per-version marker existed. Still accepted +// when deciding whether existing notes can seed the cumulative baseline. function extractPrereleaseBaseVersionMarker(notes: string): string | null { + const fullVersion = extractPrereleaseVersionMarker(notes); + if (fullVersion) { + return resolvePrereleaseBaseVersion(fullVersion); + } return //u.exec(notes)?.[1] ?? null; } +const DELTA_SECTION_HEADING_PREFIX = '## Changes since '; + +// Removes the previous run's "Changes since" section so the cumulative baseline +// fed back to Claude never carries a stale beta-to-beta delta. +function stripDeltaSection(notes: string): string { + const lines = notes.split(/\r?\n/); + const start = lines.findIndex((line) => line.startsWith(DELTA_SECTION_HEADING_PREFIX)); + if (start === -1) { + return notes; + } + let end = lines.length; + for (let index = start + 1; index < lines.length; index += 1) { + if (lines[index]!.startsWith('## ')) { + end = index; + break; + } + } + return [...lines.slice(0, start), ...lines.slice(end)].join('\n'); +} + function stripPrereleaseMetadata(notes: string): string { - return notes.replace(/\s*/u, '').trim(); + return notes + .replace(/\s*/u, '') + .replace(/\s*/u, '') + .trim(); } function resolveReusablePrereleaseNotes(notes: string, version: string): string | undefined { @@ -120,7 +172,124 @@ function resolveReusablePrereleaseNotes(notes: string, version: string): string if (existingBaseVersion !== resolvePrereleaseBaseVersion(version)) { return undefined; } - return stripPrereleaseMetadata(notes); + return stripPrereleaseMetadata(stripDeltaSection(notes)); +} + +type ParsedPrereleaseTag = { + tag: string; + base: string; + channel: 'beta' | 'rc'; + iteration: number; +}; + +function parsePrereleaseTag(tag: string): ParsedPrereleaseTag | null { + const match = /^v?(\d+\.\d+\.\d+)-(beta|rc)\.(\d+)$/u.exec(tag.trim()); + if (!match) { + return null; + } + return { + tag: tag.trim(), + base: match[1]!, + channel: match[2] as 'beta' | 'rc', + iteration: Number.parseInt(match[3]!, 10), + }; +} + +// Semver prerelease order: every beta sorts before every rc, then numerically. +function comparePrereleaseTags(a: ParsedPrereleaseTag, b: ParsedPrereleaseTag): number { + if (a.channel !== b.channel) { + return a.channel === 'beta' ? -1 : 1; + } + return a.iteration - b.iteration; +} + +// Picks the newest prerelease tag for the same base version that strictly +// precedes the version being released. Returns null for the first prerelease. +export function selectPreviousPrereleaseTag(tags: string[], version: string): string | null { + const current = parsePrereleaseTag(normalizeVersion(version)); + if (!current) { + return null; + } + + const candidates = tags + .map(parsePrereleaseTag) + .filter((parsed): parsed is ParsedPrereleaseTag => parsed !== null) + .filter((parsed) => parsed.base === current.base) + .filter((parsed) => comparePrereleaseTags(parsed, current) < 0) + .sort(comparePrereleaseTags); + + return candidates[candidates.length - 1]?.tag ?? null; +} + +function defaultListPrereleaseTags(cwd: string, baseVersion: string): string[] { + return execFileSync('git', ['tag', '--list', `v${baseVersion}-beta.*`, `v${baseVersion}-rc.*`], { + cwd, + encoding: 'utf8', + }) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +// Diffs changes/*.md between the previous prerelease tag and the working tree. +// Renamed fragments are treated as modifications of the new path. +// +// Like every other path in this script, git paths are resolved against `cwd`, +// which is the project root and also the repository root. Callers that point +// `cwd` elsewhere already fail earlier and loudly, when package.json and +// changes/ come back missing. +function defaultResolveFragmentDelta(cwd: string, previousTag: string): FragmentDeltaEntry[] { + const output = execFileSync( + 'git', + ['diff', '--name-status', '--find-renames', previousTag, '--', 'changes'], + { cwd, encoding: 'utf8' }, + ); + const showAtTag = (fragmentPath: string): string => + execFileSync('git', ['show', `${previousTag}:${fragmentPath}`], { cwd, encoding: 'utf8' }); + const readCurrent = (fragmentPath: string): string => + fs.readFileSync(path.join(cwd, fragmentPath), 'utf8'); + + const entries: FragmentDeltaEntry[] = []; + for (const line of output.split(/\r?\n/)) { + if (!line.trim()) { + continue; + } + const [status = '', ...paths] = line.split('\t'); + const oldPath = paths[0] ?? ''; + const newPath = paths[paths.length - 1] ?? ''; + if (!isFragmentPath(newPath) && !isFragmentPath(oldPath)) { + continue; + } + + if (status.startsWith('A')) { + entries.push({ path: newPath, status: 'added', after: readCurrent(newPath) }); + } else if (status.startsWith('D')) { + entries.push({ path: oldPath, status: 'deleted', before: showAtTag(oldPath) }); + } else if (status.startsWith('M') || status.startsWith('R')) { + entries.push({ + path: newPath, + status: 'modified', + before: showAtTag(oldPath), + after: readCurrent(newPath), + }); + } + } + + // git diff misses fragments that exist only in the working tree; treat + // untracked fragments as additions so a pre-commit run still sees them. + const untracked = execFileSync( + 'git', + ['ls-files', '--others', '--exclude-standard', '--', 'changes'], + { cwd, encoding: 'utf8' }, + ) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((candidate) => candidate && isFragmentPath(candidate)); + for (const fragmentPath of untracked) { + entries.push({ path: fragmentPath, status: 'added', after: readCurrent(fragmentPath) }); + } + + return entries; } function verifyRequestedVersionMatchesPackageVersion( @@ -311,10 +480,15 @@ You will receive a list of FRAGMENT entries below. Each fragment has metadata (t - Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five. - Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users. - Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules. Do not soften or omit them. -5. In MODE: changelog, each item may be a conventional single-level bullet, e.g. "- Playlist Browser: Adds faster saved-show browsing." -6. In MODE: release-notes, use short top-level change bullets with two or three nested bullets when an item needs explanation. - Nested bullets should cover the change, user benefit, and any user action or compatibility note when useful. Do not require the exact nested labels; natural phrasing is fine. Omit the action bullet when no action is needed. +5. In both modes, split every item into one nested bullet per distinct change. Write a short bold name on the top-level bullet, then indent the details two spaces: + - **Playlist Browser**: + - Saved shows now open without rescanning the library. + - The picker remembers the last folder you browsed between launches. + Each nested bullet covers exactly one change, behavior, or user-visible outcome. Never stack several distinct changes into one long paragraph-shaped bullet. + Aim for two to five nested bullets per item. When an item genuinely has only one thing to say, put it inline on the top-level bullet ("- **Playlist Browser**: Saved shows now open without rescanning the library.") instead of emitting a single nested bullet. Keep nested bullets short, concrete, and readable by non-technical users. Avoid paragraph-style release-note bullets. + Bullets inside the Internal section may stay single-level. +6. In MODE: release-notes, nested bullets should also cover user benefit and any user action or compatibility note when useful. Do not require the exact nested labels; natural phrasing is fine. Omit the action bullet when no action is needed. 7. Do not invent features. Every bullet must be grounded in the input fragments. 8. Do not include the version heading (## v...) — that wrapper is added by the caller. @@ -615,7 +789,7 @@ function polishFragmentsWithClaude( ? [ '## Existing Prerelease Notes', '', - 'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, Installation, or Assets sections.', + 'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, any "Changes since" section, or the Installation or Assets sections.', '', ].join('\n') : ''; @@ -627,6 +801,75 @@ function polishFragmentsWithClaude( return validatePolishedOutput(output, mode, hasInternalFragments); } +const DELTA_PROMPT_INSTRUCTIONS = `You are writing the "changes since the previous prerelease" section of a prerelease notes file for SubMiner, an Electron app for Japanese sentence mining. + +You will receive changelog fragment diffs between the previous prerelease tag and the current build. Fragments are engineer-written release-note sources; a fragment diff is a proxy for what changed, not proof of a behavior change. + +Rules: + +1. Output Markdown bullets ONLY. No headings, no preamble, no commentary. Every line must be a top-level "- " bullet or an indented nested bullet. +2. Describe only what changed for users between the two prerelease builds, in user-facing language. Drop implementation jargon, file paths, and PR numbers. +3. ADDED fragments describe changes that are new in this build; summarize them. +4. MODIFIED fragments include BEFORE and AFTER content. Describe only the behavioral difference between them. If the edit is editorial (rewording, deduplication, reformatting, reconciling stale phrasing) with no user-visible behavior change, omit it entirely. +5. DELETED fragments mean the described change was removed or reverted before this build; say so explicitly. +6. Keep bullets short and concrete. Use nested bullets sparingly. +7. Do not invent changes. Every bullet must be grounded in the diffs. +8. If no bullet survives rules 2-5, output exactly this single line: + - No user-facing changes since PREVIOUS_TAG. + +The input begins below. + +`; + +function serializeFragmentDeltaForPrompt( + delta: FragmentDeltaEntry[], + version: string, + previousTag: string, +): string { + const header = [`VERSION: ${version}`, `PREVIOUS_TAG: ${previousTag}`]; + const blocks = delta.map((entry) => { + if (entry.status === 'added') { + return [`ADDED FRAGMENT ${entry.path}`, entry.after ?? ''].join('\n'); + } + if (entry.status === 'deleted') { + return [`DELETED FRAGMENT ${entry.path}`, entry.before ?? ''].join('\n'); + } + return [ + `MODIFIED FRAGMENT ${entry.path}`, + 'BEFORE:', + entry.before ?? '', + 'AFTER:', + entry.after ?? '', + ].join('\n'); + }); + return [...header, '', ...blocks].join('\n\n'); +} + +function validateDeltaOutput(output: string): string { + const trimmed = output.trim(); + if (!trimmed) { + throw new Error('claude returned empty output for the prerelease delta section.'); + } + const invalidLine = trimmed.split(/\r?\n/).find((line) => line.trim() && !/^\s*- /.test(line)); + if (invalidLine !== undefined) { + throw new Error( + `claude delta output must contain only Markdown bullets. Offending line:\n${invalidLine}`, + ); + } + return trimmed; +} + +function buildDeltaSectionWithClaude( + delta: FragmentDeltaEntry[], + options: { version: string; previousTag: string; deps?: ChangelogFsDeps }, +): string { + const runClaude = options.deps?.runClaude ?? defaultRunClaude; + const prompt = + DELTA_PROMPT_INSTRUCTIONS.replace('PREVIOUS_TAG', options.previousTag) + + serializeFragmentDeltaForPrompt(delta, options.version, options.previousTag); + return validateDeltaOutput(runClaude(prompt, CLAUDE_CLI_ARGS)); +} + function stripDetailsBlocks(body: string): string { return body.replace(/
[\s\S]*?<\/details>\s*/gm, '').trim(); } @@ -709,15 +952,18 @@ function renderReleaseNotes( contributions?: Contribution[]; contributorSections?: string[]; metadata?: string[]; + deltaSection?: string[]; }, ): string { const prefix = options?.disclaimer ? [options.disclaimer, ''] : []; const metadata = options?.metadata?.length ? [...options.metadata, ''] : []; + const deltaSection = options?.deltaSection?.length ? [...options.deltaSection, ''] : []; const contributorSections = options?.contributorSections ?? renderContributorsSections(options?.contributions ?? []); return [ ...prefix, ...metadata, + ...deltaSection, '## Highlights', changes, '', @@ -748,6 +994,7 @@ function writeReleaseNotesFile( contributions?: Contribution[]; contributorSections?: string[]; metadata?: string[]; + deltaSection?: string[]; }, ): string { const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync; @@ -1079,6 +1326,26 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri throw new Error('No changelog fragments found in changes/.'); } + const listPrereleaseTags = options?.deps?.listPrereleaseTags ?? defaultListPrereleaseTags; + const previousTag = selectPreviousPrereleaseTag( + listPrereleaseTags(cwd, resolvePrereleaseBaseVersion(version)), + version, + ); + + // Later betas/RCs get a "Changes since " section on top of the + // cumulative Highlights, generated from the fragment diff between the + // previous prerelease tag and the working tree. + let deltaSection: string[] = []; + if (previousTag) { + const resolveFragmentDelta = options?.deps?.resolveFragmentDelta ?? defaultResolveFragmentDelta; + const delta = resolveFragmentDelta(cwd, previousTag); + const deltaBody = + delta.length === 0 + ? `- No changelog fragment changes since ${previousTag}; this build contains packaging or internal-only updates.` + : buildDeltaSectionWithClaude(delta, { version, previousTag, deps: options?.deps }); + deltaSection = [`${DELTA_SECTION_HEADING_PREFIX}${previousTag}`, '', deltaBody]; + } + const prereleaseNotesPath = path.join(cwd, PRERELEASE_NOTES_PATH); const existingReleaseNotes = existsSync(prereleaseNotesPath) ? resolveReusablePrereleaseNotes(readFileSync(prereleaseNotesPath, 'utf8'), version) @@ -1095,10 +1362,41 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.', outputPath: PRERELEASE_NOTES_PATH, contributions, - metadata: [renderPrereleaseBaseVersionMarker(version)], + metadata: [renderPrereleaseVersionMarker(version, previousTag)], + deltaSection, }); } +// CI gate: the committed prerelease notes must carry a marker generated for +// exactly the version being tagged, so stale beta.N-1 notes can't ship. +export function verifyPrereleaseNotesMatchVersion(options?: ChangelogOptions): void { + verifyRequestedVersionMatchesPackageVersion(options ?? {}); + + const cwd = options?.cwd ?? process.cwd(); + const existsSync = options?.deps?.existsSync ?? fs.existsSync; + const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync; + const version = resolveVersion(options ?? {}); + if (!isSupportedPrereleaseVersion(version)) { + throw new Error( + `Unsupported prerelease version (${version}). Expected x.y.z-beta.N or x.y.z-rc.N.`, + ); + } + + const prereleaseNotesPath = path.join(cwd, PRERELEASE_NOTES_PATH); + if (!existsSync(prereleaseNotesPath)) { + throw new Error( + `Missing ${prereleaseNotesPath}. Run 'bun run changelog:prerelease-notes --version ${version}' and commit the file before tagging.`, + ); + } + + const markerVersion = extractPrereleaseVersionMarker(readFileSync(prereleaseNotesPath, 'utf8')); + if (markerVersion !== version) { + throw new Error( + `release/prerelease-notes.md was generated for ${markerVersion ?? 'an unknown version (missing or legacy prerelease-version marker)'} but this release is ${version}. Rerun 'bun run changelog:prerelease-notes --version ${version}' and commit the result.`, + ); + } +} + function parseCliArgs(argv: string[]): { baseRef?: string; cwd?: string; @@ -1206,6 +1504,11 @@ function main(): void { return; } + if (command === 'check-prerelease-notes') { + verifyPrereleaseNotesMatchVersion(options); + return; + } + if (command === 'docs') { generateDocsChangelog(options); return; diff --git a/scripts/build-macos-helper.sh b/scripts/build-macos-helper.sh deleted file mode 100755 index 4f66a4b7..00000000 --- a/scripts/build-macos-helper.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash -# Build macOS window tracking helper binary - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SWIFT_SOURCE="$SCRIPT_DIR/get-mpv-window-macos.swift" -OUTPUT_DIR="$SCRIPT_DIR/../dist/scripts" -OUTPUT_BINARY="$OUTPUT_DIR/get-mpv-window-macos" -OUTPUT_SOURCE_COPY="$OUTPUT_DIR/get-mpv-window-macos.swift" - -fallback_to_source() { - echo "Falling back to source fallback: $OUTPUT_SOURCE_COPY" - mkdir -p "$OUTPUT_DIR" - cp "$SWIFT_SOURCE" "$OUTPUT_SOURCE_COPY" -} - -build_swift_helper() { - echo "Compiling macOS window tracking helper..." - if ! command -v swiftc >/dev/null 2>&1; then - echo "swiftc not found in PATH; skipping compilation." - return 1 - fi - - if ! swiftc -O "$SWIFT_SOURCE" -o "$OUTPUT_BINARY"; then - return 1 - fi - - chmod +x "$OUTPUT_BINARY" - echo "✓ Built $OUTPUT_BINARY" - return 0 -} - -# Optional skip flag for non-macOS CI/dev environments -if [[ "${SUBMINER_SKIP_MACOS_HELPER_BUILD:-}" == "1" ]]; then - echo "Skipping macOS helper build (SUBMINER_SKIP_MACOS_HELPER_BUILD=1)" - fallback_to_source - exit 0 -fi - -# Only build on macOS -if [[ "$(uname)" != "Darwin" ]]; then - echo "Skipping macOS helper build (not on macOS)" - fallback_to_source - exit 0 -fi - -# Create output directory -mkdir -p "$OUTPUT_DIR" - -# Compile Swift script to binary, fallback to source if unavailable or compilation fails -if ! build_swift_helper; then - fallback_to_source -fi diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100644 index 00000000..36105f26 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +set -euo pipefail + +FILE="${1:-}" + +if [[ ! -f "$FILE" ]]; then + printf 'Not a file: %s\n' "${FILE:-}" >&2 + exit 1 +fi + +if ! mpv --no-config --no-terminal --msg-level=all=no --vo=null --ao=null --frames=1 -- "$FILE"; then + printf 'Not playable by mpv: %s\n' "$FILE" >&2 + exit 1 +fi + +exec subminer app --dev --launch-mpv "$FILE" diff --git a/scripts/mkv-to-readme-video.sh b/scripts/mkv-to-readme-video.sh index faadd370..42963ebf 100755 --- a/scripts/mkv-to-readme-video.sh +++ b/scripts/mkv-to-readme-video.sh @@ -19,7 +19,8 @@ Options: -w, --webp Generate animated WebP preview Encoding profile: - - Crop: 1920x1080 at x=760 y=200 + - Crop: mpv region at 1920x1080, x=760 y=205 on a 3440x1440 canvas + - Output size: 1920x1080 - MP4: H.264 + AAC - WebM: AV1/VP9 + Opus at 30 fps USAGE @@ -148,7 +149,8 @@ pick_webp_encoder() { return 1 } -crop_vf="crop=1920:1080:760:205" +# OBS may resize the 3440x1440 canvas, so scale the mpv bounds with the input. +crop_vf="crop=1920*iw/3440:1080*ih/1440:760*iw/3440:205*ih/1440,scale=1920:1080:flags=lanczos" webm_vf="${crop_vf},fps=30" echo "Generating MP4: $mp4_out" diff --git a/scripts/mkv-to-readme-video.test.ts b/scripts/mkv-to-readme-video.test.ts index 5ff99b09..91c1b96b 100644 --- a/scripts/mkv-to-readme-video.test.ts +++ b/scripts/mkv-to-readme-video.test.ts @@ -40,7 +40,7 @@ function toBashPath(filePath: string): string { return `${drive.toUpperCase()}:/${rest}`; } -test('mkv-to-readme-video accepts libwebp_anim when libwebp is unavailable', () => { +test('mkv-to-readme-video builds every output with the scaled mpv crop', () => { withTempDir((root) => { const binDir = path.join(root, 'bin'); const inputPath = path.join(root, 'sample.mkv'); @@ -104,5 +104,9 @@ touch "$output" const ffmpegLog = fs.readFileSync(ffmpegLogPath, 'utf8'); assert.match(ffmpegLog, /-c:v libwebp_anim/); + const scaledCropUses = ffmpegLog.match( + /-vf crop=1920\*iw\/3440:1080\*ih\/1440:760\*iw\/3440:205\*ih\/1440,scale=1920:1080:flags=lanczos/g, + ); + assert.equal(scaledCropUses?.length, 4); }); }); diff --git a/scripts/prepare-build-assets.mjs b/scripts/prepare-build-assets.mjs index fad72b3c..1afc630d 100644 --- a/scripts/prepare-build-assets.mjs +++ b/scripts/prepare-build-assets.mjs @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; @@ -52,6 +53,16 @@ function fallbackToMacosSource() { process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`); } +// Pin the minimum macOS to the app's own floor (Electron's `minos`). Without an +// explicit target, swiftc stamps the build machine's OS version as the binary's +// minimum and the helper fails to load on older systems (#213). The arch stays +// the host's, matching the single-arch app electron-builder packages here. +const MACOS_HELPER_DEPLOYMENT_TARGET = '12.0'; + +function macosHelperTarget() { + return `${os.arch() === 'x64' ? 'x86_64' : 'arm64'}-apple-macos${MACOS_HELPER_DEPLOYMENT_TARGET}`; +} + function shouldSkipMacosHelperBuild() { return process.env.SUBMINER_SKIP_MACOS_HELPER_BUILD === '1'; } @@ -72,9 +83,13 @@ function buildMacosHelper() { ensureDir(scriptsOutputDir); try { - execFileSync('swiftc', ['-O', macosHelperSourcePath, '-o', macosHelperBinaryPath], { - stdio: 'inherit', - }); + execFileSync( + 'swiftc', + ['-O', '-target', macosHelperTarget(), macosHelperSourcePath, '-o', macosHelperBinaryPath], + { + stdio: 'inherit', + }, + ); fs.chmodSync(macosHelperBinaryPath, 0o755); process.stdout.write(`Built macOS helper: ${macosHelperBinaryPath}\n`); } catch (error) { diff --git a/scripts/prepare-build-assets.test.ts b/scripts/prepare-build-assets.test.ts index 13f64fe2..d710f8b9 100644 --- a/scripts/prepare-build-assets.test.ts +++ b/scripts/prepare-build-assets.test.ts @@ -8,7 +8,7 @@ test('macOS helper build creates dist scripts directory before swiftc output', ( const buildFunctionIndex = source.indexOf('function buildMacosHelper()'); assert.notEqual(buildFunctionIndex, -1); - const swiftcIndex = source.indexOf("execFileSync('swiftc'", buildFunctionIndex); + const swiftcIndex = source.indexOf("'swiftc'", buildFunctionIndex); assert.notEqual(swiftcIndex, -1); const ensureDirIndex = source.lastIndexOf('ensureDir(scriptsOutputDir)', swiftcIndex); @@ -18,3 +18,10 @@ test('macOS helper build creates dist scripts directory before swiftc output', ( 'buildMacosHelper must create dist/scripts before swiftc writes the helper binary', ); }); + +// Regression guard for #213: an untargeted swiftc stamps the build machine's OS +// version as the helper's minimum, so released builds refuse to load on older macOS. +test('macOS helper is compiled with an explicit deployment target', () => { + assert.match(source, /-target/); + assert.match(source, /apple-macos\$\{MACOS_HELPER_DEPLOYMENT_TARGET\}/); +}); diff --git a/scripts/update-aur-package.test.ts b/scripts/update-aur-package.test.ts index d8320d64..70a46799 100644 --- a/scripts/update-aur-package.test.ts +++ b/scripts/update-aur-package.test.ts @@ -82,6 +82,7 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => { pkgbuild, /^\s*install -Dm755 "\$\{srcdir\}\/subminer-\$\{pkgver\}" "\$\{pkgdir\}\/usr\/bin\/subminer"$/m, ); + assert.match(pkgbuild, /assets\/thumbnailers\/subminer-ffmpegthumbnailer\.thumbnailer/); assert.match(srcinfo, /^\tpkgver = 0\.6\.3$/m); assert.match(srcinfo, /^\tprovides = subminer=0\.6\.3$/m); assert.match( diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 4182cd12..1be74119 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -11,10 +11,12 @@ import type { MediaInput } from './media-input'; import { AnkiConnectConfig } from './types'; type TestOverlayNotificationPayload = { + id?: string; title: string; body?: string; image?: string; variant?: string; + persistent?: boolean; actions?: Array<{ id: string; label: string; noteId?: number }>; }; @@ -607,6 +609,7 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id' const integration = new AnkiIntegration( { fields: { + audio: 'ExpressionAudio', image: 'Picture', }, media: { @@ -660,7 +663,7 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id' noteIds.map((noteId) => ({ noteId, fields: { - SentenceAudio: { value: '' }, + ExpressionAudio: { value: '' }, Picture: { value: '' }, }, })), @@ -945,7 +948,7 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs noteInfo: { noteId: 404, fields: { - SentenceAudio: { value: '' }, + ExpressionAudio: { value: '' }, Picture: { value: '' }, }, }, @@ -957,7 +960,8 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs assert.equal(queued, true); assert.equal(updatedNotes.length, 1); assert.equal(updatedNotes[0]?.noteId, 404); - assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/); + assert.match(updatedNotes[0]?.fields.ExpressionAudio ?? '', /^\[sound:audio_/); + assert.equal(updatedNotes[0]?.fields.SentenceAudio, undefined); assert.match(updatedNotes[0]?.fields.Picture ?? '', /^$/); }); diff --git a/src/anki-integration/pending-youtube-media-queue.ts b/src/anki-integration/pending-youtube-media-queue.ts index 68abd2c9..0cd09195 100644 --- a/src/anki-integration/pending-youtube-media-queue.ts +++ b/src/anki-integration/pending-youtube-media-queue.ts @@ -39,7 +39,6 @@ export interface PendingYoutubeMediaQueueDeps { startTime: number; endTime: number; }; - getResolvedSentenceAudioFieldName: (noteInfo: PendingYoutubeMediaNoteInfo) => string | null; resolveConfiguredFieldName: ( noteInfo: PendingYoutubeMediaNoteInfo, ...preferredNames: (string | undefined)[] @@ -136,7 +135,7 @@ export class PendingYoutubeMediaQueue { startTime: mediaRange.startTime, endTime: mediaRange.endTime, label: job.label, - audioFieldName: this.deps.getResolvedSentenceAudioFieldName(job.noteInfo) ?? undefined, + audioFieldName: this.resolveConfiguredAudioFieldName(job.noteInfo) ?? undefined, imageFieldName: this.deps.resolveConfiguredFieldName( job.noteInfo, @@ -247,6 +246,14 @@ export class PendingYoutubeMediaQueue { return matched; } + private resolveConfiguredAudioFieldName(noteInfo: PendingYoutubeMediaNoteInfo): string | null { + const config = this.deps.getConfig(); + return this.deps.resolveConfiguredFieldName( + noteInfo, + config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio, + ); + } + private async applyUpdate( job: PendingYoutubeMediaUpdate, cachedPath: string, @@ -283,7 +290,7 @@ export class PendingYoutubeMediaQueue { if (audioBuffer) { await this.deps.client.storeMediaFile(audioFilename, audioBuffer); const audioField = - job.audioFieldName || this.deps.getResolvedSentenceAudioFieldName(noteInfo) || null; + job.audioFieldName || this.resolveConfiguredAudioFieldName(noteInfo) || null; if (audioField) { const existingAudio = noteInfo.fields[audioField]?.value || ''; mediaFields[audioField] = this.deps.mergeFieldValue( diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index a7c2a5dd..bc9f3674 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -284,6 +284,22 @@ function createMockTracker( getSessionTimeline: async () => [], getSessionEvents: async () => [], getVocabularyStats: async () => VOCABULARY_STATS, + getVocabularySummary: async () => ({ + uniqueWords: 501, + uniqueWordsWithoutNames: 500, + uniqueKanji: 201, + newThisWeek: 7, + newThisWeekWithoutNames: 6, + knownWordCount: 250, + knownWordCountWithoutNames: 249, + }), + getVocabularyChartData: async () => ({ + ready: true, + topWords: [{ wordId: 1, headword: 'する', frequency: 50 }], + topWordsWithoutNames: [{ wordId: 1, headword: 'する', frequency: 50 }], + newWordsTimeline: [{ epochDay: 20_000, wordCount: 3 }], + newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 3 }], + }), getStatsExcludedWords: async () => [], replaceStatsExcludedWords: async () => {}, getKanjiStats: async () => KANJI_STATS, @@ -711,6 +727,38 @@ describe('stats server API routes', () => { assert.equal(body[0].headword, 'する'); }); + it('GET /api/stats/vocabulary/summary returns database-wide card totals', async () => { + const app = createStatsApp(createMockTracker()); + + const res = await app.request('/api/stats/vocabulary/summary'); + + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { + uniqueWords: 501, + uniqueWordsWithoutNames: 500, + uniqueKanji: 201, + newThisWeek: 7, + newThisWeekWithoutNames: 6, + knownWordCount: 250, + knownWordCountWithoutNames: 249, + }); + }); + + it('GET /api/stats/vocabulary/charts returns complete chart datasets', async () => { + const app = createStatsApp(createMockTracker()); + + const res = await app.request('/api/stats/vocabulary/charts'); + + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { + ready: true, + topWords: [{ wordId: 1, headword: 'する', frequency: 50 }], + topWordsWithoutNames: [{ wordId: 1, headword: 'する', frequency: 50 }], + newWordsTimeline: [{ epochDay: 20_000, wordCount: 3 }], + newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 3 }], + }); + }); + it('GET /api/stats/kanji returns kanji frequency data', async () => { const app = createStatsApp(createMockTracker()); const res = await app.request('/api/stats/kanji'); diff --git a/src/core/services/anki-jimaku.ts b/src/core/services/anki-jimaku.ts index 479b72e2..976db6db 100644 --- a/src/core/services/anki-jimaku.ts +++ b/src/core/services/anki-jimaku.ts @@ -65,6 +65,7 @@ export interface AnkiJimakuIpcRuntimeOptions { getYoutubeMediaSourceUrl?: () => Promise | string | null | undefined; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -166,6 +167,7 @@ export function registerAnkiJimakuIpcRuntime( options.getCachedMediaPath, options.shouldRequireRemoteMediaCache, options.getYoutubeMediaSourceUrl, + options.dismissOverlayNotification, ); integration.start(); options.setAnkiIntegration(integration); diff --git a/src/core/services/ass-text.test.ts b/src/core/services/ass-text.test.ts index 216c23f8..f7d6edfa 100644 --- a/src/core/services/ass-text.test.ts +++ b/src/core/services/ass-text.test.ts @@ -10,6 +10,8 @@ import { isAssTemporalCommand, normalizePlainSubtitleText, parseAssEffectField, + removeLiveGlyphFragmentLines, + removeAssControlDebrisLines, } from './ass-text'; test('assToPlainText drops vector drawing runs', () => { @@ -74,6 +76,14 @@ test('assToPlainText normalizes CRLF before converting', () => { assert.equal(assToPlainText('一行目\r\n二行目'), '一行目\n二行目'); }); +test('removeAssControlDebrisLines drops malformed spacer resets without eating dialogue', () => { + assert.equal( + removeAssControlDebrisLines('Visible line\n\\\n{\\fr0\n\\{\\frz287.5'), + 'Visible line', + ); + assert.equal(removeAssControlDebrisLines('本文{\\pos(1,2)'), '本文{\\pos(1,2)'); +}); + test('normalizePlainSubtitleText settles whitespace without decoding ASS', () => { // A brace reaching this layer is literal text mpv chose to show, not markup. assert.equal(normalizePlainSubtitleText('本文{\\pos(1,2)'), '本文{\\pos(1,2)'); @@ -193,3 +203,33 @@ test('isAnimatedAssEffectKind covers the stock animated effects only', () => { assert.equal(isAnimatedAssEffectKind('other'), false); assert.equal(isAnimatedAssEffectKind('none'), false); }); + +test('removeLiveGlyphFragmentLines drops a per-glyph typesetting wall and its syllable', () => { + const wall = [...'wansdumretoikhI'].join('\n'); + assert.equal(removeLiveGlyphFragmentLines(`${wall}\ntai`), ''); +}); + +test('removeLiveGlyphFragmentLines keeps concurrent dialogue beside a glyph wall', () => { + const wall = [...'wansdumretoikhI'].join('\n'); + assert.equal(removeLiveGlyphFragmentLines(`${wall}\nそれよりも ノート…`), 'それよりも ノート…'); +}); + +test('removeLiveGlyphFragmentLines leaves ordinary short lines alone', () => { + const text = 'え\nはい。\nそうだな'; + assert.equal(removeLiveGlyphFragmentLines(text), text); +}); + +test('normalizePlainSubtitleText folds cue-boundary blank lines for text consumers', () => { + // The display layer splits on the blank line before normalizing; everyone else -- + // tokenizer, cache key, dedup gate, mined sentence -- wants the plain line form. + assert.equal( + normalizePlainSubtitleText('\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee'), + '\u4e00\u884c\u76ee\n\u4e8c\u884c\u76ee', + ); + assert.equal( + normalizePlainSubtitleText('\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee', { + collapseLineBreaks: true, + }), + '\u4e00\u884c\u76ee \u4e8c\u884c\u76ee', + ); +}); diff --git a/src/core/services/ass-text.ts b/src/core/services/ass-text.ts index ca05b7fd..edb30dda 100644 --- a/src/core/services/ass-text.ts +++ b/src/core/services/ass-text.ts @@ -91,6 +91,41 @@ export function assToPlainText(text: string, lineBreak: AssLineBreak = '\n'): st return resolveWhitespaceEscapes(stripAssMarkup(text.replace(/\r\n/g, '\n')), lineBreak); } +const MALFORMED_ASS_ROTATION_RESET = /^\\?\{\\(?:fr|frx|fry|frz|fax|fay)[-+.0-9]*$/u; + +/** + * Drop non-rendering spacer events left as literal text by a malformed, unclosed ASS + * rotation reset. These events otherwise become repeated `\\` or `{\\fr0` subtitle + * lines after mpv-compatible decoding. + */ +export function removeAssControlDebrisLines(text: string): string { + return text + .split('\n') + .filter((line) => { + const compact = line.replace(/\s+/gu, ''); + return compact !== '\\' && !MALFORMED_ASS_ROTATION_RESET.test(compact); + }) + .join('\n'); +} + +const MIN_GLYPH_BURST_LINES = 6; +const MAX_GLYPH_BURST_COMPANION_GLYPHS = 3; + +/** + * Per-glyph karaoke typesetting flattened into live text becomes a wall of + * single-character lines plus the short syllable currently being typed. No authored + * subtitle stacks this many one-glyph lines at once, so when the wall is present drop + * it and its short companion fragments while keeping any concurrent dialogue line. + */ +export function removeLiveGlyphFragmentLines(text: string): string { + const lines = text.split('\n'); + const singleGlyphLines = lines.filter((line) => [...line.trim()].length === 1).length; + if (singleGlyphLines < MIN_GLYPH_BURST_LINES) return text; + return lines + .filter((line) => [...line.trim()].length > MAX_GLYPH_BURST_COMPANION_GLYPHS) + .join('\n'); +} + export interface NormalizePlainSubtitleTextOptions { /** Fold every line break into a single space. */ collapseLineBreaks?: boolean; @@ -118,6 +153,10 @@ export function normalizePlainSubtitleText( ); if (collapseLineBreaks) { normalized = normalized.replace(/\n/g, ' ').replace(/\s+/g, ' '); + } else { + // Simultaneous cues reach the display layer separated by a blank line; every other + // consumer wants the plain one-break-per-line form. + normalized = normalized.replace(/\n{2,}/g, '\n'); } return trim ? normalized.trim() : normalized; diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index e9f075da..ae995172 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -559,6 +559,241 @@ test('fresh tracker DB creates lifetime summary tables', async () => { } }); +test('fresh tracker DB skips lexical rollup backfill work', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + let backfillRuns = 0; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }, { + runLexicalRollupBackfillTask: async () => { + backfillRuns += 1; + }, + } as never); + + assert.equal(backfillRuns, 0); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +test('tracker starts the injected lexical rollup backfill when it is pending', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + let backfillRuns = 0; + + try { + const setupDb = new Database(dbPath); + const { ensureSchema } = await import('./immersion-tracker/storage'); + ensureSchema(setupDb); + setupDb + .prepare( + `UPDATE imm_rollup_state SET state_value = '0' WHERE state_key = 'lexical_daily_rollups_version'`, + ) + .run(); + setupDb.close(); + + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }, { + runLexicalRollupBackfillTask: async () => { + backfillRuns += 1; + }, + } as never); + + assert.equal(backfillRuns, 1); + await waitForCondition( + () => !(tracker as unknown as { writeLock: { locked: boolean } }).writeLock.locked, + ); + assert.equal( + (tracker as unknown as { preserveWriteQueueUntilDrained: boolean }) + .preserveWriteQueueUntilDrained, + false, + ); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +test('tracker runs startup session-rollup maintenance before lexical backfill locks writes', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + let releaseBackfill = (): void => {}; + const heldBackfill = new Promise((resolve) => { + releaseBackfill = resolve; + }); + + try { + const startedAtMs = trackerNowMs() - 60_000; + const endedAtMs = trackerNowMs(); + const setupDb = new Database(dbPath); + const { ensureSchema } = await import('./immersion-tracker/storage'); + ensureSchema(setupDb); + setupDb.exec(` + INSERT INTO imm_videos ( + video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE + ) VALUES (1, 'local:/tmp/rollup-recovery.mkv', 'Rollup Recovery', 1, 0, '1', '1'); + INSERT INTO imm_sessions ( + session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, + active_watched_ms, lines_seen, tokens_seen, cards_mined, CREATED_DATE, LAST_UPDATE_DATE + ) VALUES ( + 1, 'rollup-recovery', 1, '${startedAtMs}', '${endedAtMs}', 2, + 60000, 10, 20, 2, '${startedAtMs}', '${endedAtMs}' + ); + INSERT INTO imm_session_telemetry ( + session_id, sample_ms, total_watched_ms, active_watched_ms, lines_seen, + tokens_seen, cards_mined, lookup_count, lookup_hits, CREATED_DATE, LAST_UPDATE_DATE + ) VALUES ( + 1, '${endedAtMs}', 60000, 60000, 10, 20, 2, 0, 0, + '${endedAtMs}', '${endedAtMs}' + ); + DELETE FROM imm_daily_rollups; + DELETE FROM imm_monthly_rollups; + UPDATE imm_rollup_state SET state_value = '0'; + `); + setupDb.close(); + + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }, { + runLexicalRollupBackfillTask: async () => heldBackfill, + } as never); + + const privateApi = tracker as unknown as { + db: DatabaseSync; + writeLock: { locked: boolean }; + }; + assert.equal(privateApi.writeLock.locked, true); + assert.equal( + ( + privateApi.db.prepare('SELECT COUNT(*) AS total FROM imm_daily_rollups').get() as { + total: number; + } + ).total, + 1, + ); + assert.equal( + ( + privateApi.db.prepare('SELECT COUNT(*) AS total FROM imm_monthly_rollups').get() as { + total: number; + } + ).total, + 1, + ); + } finally { + releaseBackfill(); + if (tracker) { + await waitForCondition( + () => !(tracker as unknown as { writeLock: { locked: boolean } }).writeLock.locked, + ); + } + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +test('tracker queues playback writes until lexical rollup backfill settles', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + let startBackfill = (): void => {}; + let releaseBackfill = (): void => {}; + let markBackfillStarted = (): void => {}; + const backfillStartGate = new Promise((resolve) => { + startBackfill = resolve; + }); + const heldBackfill = new Promise((resolve) => { + releaseBackfill = resolve; + }); + const backfillStarted = new Promise((resolve) => { + markBackfillStarted = resolve; + }); + + try { + const setupDb = new Database(dbPath); + const { ensureSchema } = await import('./immersion-tracker/storage'); + ensureSchema(setupDb); + setupDb + .prepare( + `UPDATE imm_rollup_state SET state_value = '0' WHERE state_key = 'lexical_daily_rollups_version'`, + ) + .run(); + setupDb.close(); + + const Ctor = await loadTrackerCtor(); + tracker = new Ctor( + { dbPath, policy: { queueCap: 100 } }, + { + runLexicalRollupBackfillTask: async (workerDbPath) => { + await backfillStartGate; + const workerDb = new Database(workerDbPath); + try { + workerDb.exec('BEGIN IMMEDIATE'); + markBackfillStarted(); + await heldBackfill; + workerDb.exec('COMMIT'); + } catch (error) { + try { + workerDb.exec('ROLLBACK'); + } catch { + // Preserve the original worker failure. + } + throw error; + } finally { + workerDb.close(); + } + }, + }, + ); + tracker.handleMediaChange('https://example.com/backfill-test.mp4', 'Backfill Test'); + startBackfill(); + await backfillStarted; + for (let index = 0; index < 125; index += 1) tracker.recordCardsMined(1); + + const privateApi = tracker as unknown as { + db: DatabaseSync; + queue: unknown[]; + droppedWriteCount: number; + flushNow: () => void; + writeLock: { locked: boolean }; + }; + assert.equal(privateApi.writeLock.locked, true); + privateApi.flushNow(); + + assert.ok(privateApi.queue.length > 100, 'the protected queue may grow past its normal cap'); + assert.equal(privateApi.droppedWriteCount, 0, 'backfill must not discard playback writes'); + assert.equal( + ( + privateApi.db.prepare('SELECT COUNT(*) AS total FROM imm_session_events').get() as { + total: number; + } + ).total, + 0, + ); + + releaseBackfill(); + await waitForCondition(() => privateApi.queue.length === 0, 5_000); + assert.equal( + ( + privateApi.db.prepare('SELECT COUNT(*) AS total FROM imm_session_events').get() as { + total: number; + } + ).total, + 125, + ); + } finally { + releaseBackfill(); + if (tracker) { + await waitForCondition( + () => !(tracker as unknown as { writeLock: { locked: boolean } }).writeLock.locked, + 5_000, + ); + } + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + test('startup backfills lifetime summaries when retained sessions exist but summary tables are empty', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; @@ -4909,3 +5144,149 @@ test('ensureAnimeCoverArt fetches art via the latest video of the anime', async cleanupDbPath(dbPath); } }); + +test('getVocabularySummary coalesces concurrent requests into one worker task', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + let taskRuns = 0; + let releaseTask: (() => void) | null = null; + const seenKnownWords: Array | null> = []; + const summary = { + uniqueWords: 1, + uniqueWordsWithoutNames: 1, + uniqueKanji: 0, + newThisWeek: 0, + newThisWeekWithoutNames: 0, + knownWordCount: null, + knownWordCountWithoutNames: null, + }; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor( + { dbPath }, + { + runVocabularySummaryTask: async (_dbPath, knownWords) => { + taskRuns += 1; + seenKnownWords.push(knownWords); + await new Promise((resolve) => { + releaseTask = resolve; + }); + return summary; + }, + destroyVocabularySummaryRunner: () => {}, + }, + ); + + const knownWordsSnapshot = new Set(['猫']); + const first = tracker.getVocabularySummary(knownWordsSnapshot); + const second = tracker.getVocabularySummary(knownWordsSnapshot); + await waitForCondition(() => releaseTask !== null); + let release = releaseTask as (() => void) | null; + assert.ok(release); + release(); + assert.deepEqual(await first, summary); + assert.equal(await second, await first); + assert.equal(taskRuns, 1); + assert.deepEqual(seenKnownWords, [knownWordsSnapshot]); + + releaseTask = null; + const third = tracker.getVocabularySummary(null); + await waitForCondition(() => releaseTask !== null); + release = releaseTask as (() => void) | null; + assert.ok(release); + release(); + assert.deepEqual(await third, summary); + assert.equal(taskRuns, 2); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +test('getVocabularySummary coalesces equivalent known-word snapshots by value', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + let taskRuns = 0; + const releases: Array<() => void> = []; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor( + { dbPath }, + { + runVocabularySummaryTask: async () => { + taskRuns += 1; + await new Promise((resolve) => releases.push(resolve)); + return { + uniqueWords: 2, + uniqueWordsWithoutNames: 2, + uniqueKanji: 2, + newThisWeek: 0, + newThisWeekWithoutNames: 0, + knownWordCount: 2, + knownWordCountWithoutNames: 2, + }; + }, + destroyVocabularySummaryRunner: () => {}, + }, + ); + + const first = tracker.getVocabularySummary(new Set(['猫', '犬'])); + const second = tracker.getVocabularySummary(new Set(['犬', '猫'])); + await waitForCondition(() => releases.length > 0); + const observedTaskRuns = taskRuns; + for (const release of releases) release(); + await Promise.all([first, second]); + + assert.equal(observedTaskRuns, 1); + + const third = tracker.getVocabularySummary(new Set(['猫', '犬'])); + await waitForCondition(() => releases.length === 2); + releases[1]!(); + await third; + assert.equal(taskRuns, 2, 'a settled snapshot must be evicted from the in-flight map'); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + +test('getVocabularySummary keeps different known-word snapshots independent', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + const releases: Array<() => void> = []; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor( + { dbPath }, + { + runVocabularySummaryTask: async (_dbPath, knownWords) => { + await new Promise((resolve) => releases.push(resolve)); + return { + uniqueWords: 1, + uniqueWordsWithoutNames: 1, + uniqueKanji: 0, + newThisWeek: 0, + newThisWeekWithoutNames: 0, + knownWordCount: knownWords?.size ?? null, + knownWordCountWithoutNames: knownWords?.size ?? null, + }; + }, + destroyVocabularySummaryRunner: () => {}, + }, + ); + + const withoutKnownWords = tracker.getVocabularySummary(null); + const withKnownWords = tracker.getVocabularySummary(new Set(['猫'])); + await waitForCondition(() => releases.length === 2); + for (const release of releases) release(); + + assert.equal((await withoutKnownWords).knownWordCount, null); + assert.equal((await withKnownWords).knownWordCount, 1); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index d4efbdd0..74f34bea 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -58,6 +58,7 @@ import { getSessionEvents, getSimilarWords, getStatsExcludedWords, + getVocabularyChartData, getVocabularyStats, replaceStatsExcludedWords, searchSubtitleSentences, @@ -96,6 +97,12 @@ import { DeleteMaintenanceWorkerRuntime, type RunDeleteMaintenanceTask, } from './immersion-tracker/delete-maintenance-worker-runtime'; +import { + VocabularySummaryWorkerRuntime, + type RunVocabularySummaryTask, +} from './immersion-tracker/vocabulary-summary-worker-runtime'; +import { LexicalRollupWorkerRuntime } from './immersion-tracker/lexical-rollup-worker-runtime'; +import { areLexicalDailyRollupsReady } from './immersion-tracker/lexical-rollups'; import { DeleteMaintenanceScheduler } from './immersion-tracker/delete-maintenance-scheduler'; import { cleanupDuplicateSubtitleLines, @@ -185,6 +192,7 @@ import { type StatsExcludedWordRow, type StreakCalendarRow, type VocabularyCleanupSummary, + type VocabularyStatsSummary, type WatchTimePerAnimeRow, type WordAnimeAppearanceRow, type WordDetailRow, @@ -405,13 +413,24 @@ export class ImmersionTrackerService { private readonly monthlyRollupRetentionMs: number; private readonly vacuumIntervalMs: number; private readonly dbPath: string; - private readonly writeLock = { locked: false }; + private readonly writeLock = { + locked: false, + reasons: new Set<'flush' | 'delete-maintenance' | 'lexical-rollup-backfill'>(), + }; private readonly destroyDeleteMaintenanceRunner: () => void; + private readonly runVocabularySummaryTask: ( + knownWords: ReadonlySet | null, + ) => Promise; + private readonly vocabularySummariesInFlight = new Map>(); + private readonly destroyVocabularySummaryRunner: () => void; + private readonly runLexicalRollupBackfillTask: () => Promise; + private readonly destroyLexicalRollupBackfillRunner: () => void; private readonly deleteMaintenanceScheduler: DeleteMaintenanceScheduler; private flushTimer: ReturnType | null = null; private maintenanceTimer: ReturnType | null = null; private flushScheduled = false; private droppedWriteCount = 0; + private preserveWriteQueueUntilDrained = false; private lastVacuumMs = 0; private isDestroyed = false; private sessionState: SessionState | null = null; @@ -434,6 +453,10 @@ export class ImmersionTrackerService { dependencies: { runDeleteMaintenanceTask?: RunDeleteMaintenanceTask; destroyDeleteMaintenanceRunner?: () => void; + runVocabularySummaryTask?: RunVocabularySummaryTask; + destroyVocabularySummaryRunner?: () => void; + runLexicalRollupBackfillTask?: (dbPath: string) => Promise; + destroyLexicalRollupBackfillRunner?: () => void; } = {}, ) { this.dbPath = options.dbPath; @@ -453,13 +476,34 @@ export class ImmersionTrackerService { runTask: (task) => runDeleteMaintenanceTask(this.dbPath, task), onBusy: () => { this.requireWriteQueueDrained('delete maintenance'); - this.writeLock.locked = true; + this.setWriteLock('delete-maintenance', true); }, onIdle: () => { - this.writeLock.locked = false; + this.setWriteLock('delete-maintenance', false); if (!this.isDestroyed && this.queue.length > 0) this.scheduleFlush(0); }, }); + if (dependencies.runVocabularySummaryTask) { + this.runVocabularySummaryTask = (knownWords) => + dependencies.runVocabularySummaryTask!(this.dbPath, knownWords); + this.destroyVocabularySummaryRunner = + dependencies.destroyVocabularySummaryRunner ?? (() => {}); + } else { + const vocabularySummaryRuntime = new VocabularySummaryWorkerRuntime(); + this.runVocabularySummaryTask = (knownWords) => + vocabularySummaryRuntime.run(this.dbPath, knownWords); + this.destroyVocabularySummaryRunner = () => vocabularySummaryRuntime.destroy(); + } + if (dependencies.runLexicalRollupBackfillTask) { + this.runLexicalRollupBackfillTask = () => + dependencies.runLexicalRollupBackfillTask!(this.dbPath); + this.destroyLexicalRollupBackfillRunner = + dependencies.destroyLexicalRollupBackfillRunner ?? (() => {}); + } else { + const lexicalRollupRuntime = new LexicalRollupWorkerRuntime(); + this.runLexicalRollupBackfillTask = () => lexicalRollupRuntime.run(this.dbPath); + this.destroyLexicalRollupBackfillRunner = () => lexicalRollupRuntime.destroy(); + } const parentDir = path.dirname(this.dbPath); if (!fs.existsSync(parentDir)) { fs.mkdirSync(parentDir, { recursive: true }); @@ -548,6 +592,7 @@ export class ImmersionTrackerService { } this.preparedStatements = createTrackerPreparedStatements(this.db); this.scheduleMaintenance(); + if (!areLexicalDailyRollupsReady(this.db)) this.startLexicalRollupBackfill(); this.scheduleFlush(); } @@ -565,6 +610,8 @@ export class ImmersionTrackerService { this.isDestroyed = true; this.deleteMaintenanceScheduler.destroy(); this.destroyDeleteMaintenanceRunner(); + this.destroyVocabularySummaryRunner(); + this.destroyLexicalRollupBackfillRunner(); this.db.close(); } @@ -634,6 +681,25 @@ export class ImmersionTrackerService { return getVocabularyStats(this.db, limit, excludePos); } + async getVocabularySummary(knownWords: ReadonlySet | null) { + const key = knownWords ? JSON.stringify([...knownWords].sort()) : 'null'; + const inFlight = this.vocabularySummariesInFlight.get(key); + if (inFlight) return inFlight; + const task = this.runVocabularySummaryTask(knownWords); + this.vocabularySummariesInFlight.set(key, task); + try { + return await task; + } finally { + if (this.vocabularySummariesInFlight.get(key) === task) { + this.vocabularySummariesInFlight.delete(key); + } + } + } + + async getVocabularyChartData() { + return getVocabularyChartData(this.db); + } + async getStatsExcludedWords(): Promise { return getStatsExcludedWords(this.db); } @@ -910,6 +976,33 @@ export class ImmersionTrackerService { } } + private setWriteLock( + reason: 'flush' | 'delete-maintenance' | 'lexical-rollup-backfill', + active: boolean, + ): void { + if (active) this.writeLock.reasons.add(reason); + else this.writeLock.reasons.delete(reason); + this.writeLock.locked = this.writeLock.reasons.size > 0; + } + + private startLexicalRollupBackfill(): void { + this.requireWriteQueueDrained('lexical rollup backfill'); + this.preserveWriteQueueUntilDrained = true; + this.setWriteLock('lexical-rollup-backfill', true); + void this.runLexicalRollupBackfillTask() + .catch((error: unknown) => { + this.logger.warn( + 'Lexical daily rollup backfill failed; it will retry on next startup', + error, + ); + }) + .finally(() => { + this.setWriteLock('lexical-rollup-backfill', false); + if (this.queue.length === 0) this.preserveWriteQueueUntilDrained = false; + else if (!this.isDestroyed) this.scheduleFlush(0); + }); + } + async reassignAnimeAnilist( animeId: number, info: { @@ -1906,7 +1999,12 @@ export class ImmersionTrackerService { private recordWrite(write: QueuedWrite): void { if (this.isDestroyed) return; - const { dropped } = enqueueWrite(this.queue, write, this.queueCap); + // A lexical migration owns the database write lock, so dropping the oldest + // entry cannot relieve pressure: nothing can flush until the worker exits. + // Preserve that finite startup burst and drain it as soon as the lock lifts. + const { dropped } = this.preserveWriteQueueUntilDrained + ? (this.queue.push(write), { dropped: 0 }) + : enqueueWrite(this.queue, write, this.queueCap); if (dropped > 0) { this.droppedWriteCount += dropped; this.logger.warn(`Immersion tracker queue overflow; dropped ${dropped} oldest writes`); @@ -1954,6 +2052,7 @@ export class ImmersionTrackerService { private flushNow(): void { if (this.writeLock.locked || this.isDestroyed) return; if (this.queue.length === 0) { + this.preserveWriteQueueUntilDrained = false; this.flushScheduled = false; return; } @@ -1965,7 +2064,7 @@ export class ImmersionTrackerService { } const batch = this.queue.splice(0, Math.min(this.batchSize, this.queue.length)); - this.writeLock.locked = true; + this.setWriteLock('flush', true); try { this.db.exec('BEGIN IMMEDIATE'); for (const write of batch) { @@ -1977,8 +2076,9 @@ export class ImmersionTrackerService { this.queue.unshift(...batch); this.logger.warn('Immersion tracker flush failed, retrying later', error as Error); } finally { - this.writeLock.locked = false; + this.setWriteLock('flush', false); this.flushScheduled = false; + if (this.queue.length === 0) this.preserveWriteQueueUntilDrained = false; if (this.queue.length > 0) { this.scheduleFlush(this.flushIntervalMs); } diff --git a/src/core/services/immersion-tracker/__tests__/duplicate-line-cleanup.test.ts b/src/core/services/immersion-tracker/__tests__/duplicate-line-cleanup.test.ts index 65f90743..029b125c 100644 --- a/src/core/services/immersion-tracker/__tests__/duplicate-line-cleanup.test.ts +++ b/src/core/services/immersion-tracker/__tests__/duplicate-line-cleanup.test.ts @@ -1,7 +1,4 @@ import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; import test from 'node:test'; import { Database } from '../sqlite.js'; import type { DatabaseSync } from '../sqlite.js'; @@ -21,17 +18,6 @@ interface SeedLine { createdMs?: number; } -function makeDbPath(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-duplicate-line-test-')); - return path.join(dir, 'immersion.sqlite'); -} - -function cleanupDbPath(dbPath: string): void { - const dir = path.dirname(dbPath); - if (!fs.existsSync(dir)) return; - fs.rmSync(dir, { recursive: true, force: true }); -} - /** One episode, two sessions of it, and one word occurrence per seeded line. */ function seed(db: DatabaseSync, lines: SeedLine[]): void { db.exec(` @@ -82,12 +68,16 @@ function seed(db: DatabaseSync, lines: SeedLine[]): void { `); } -function createDb(lines: SeedLine[]): { db: DatabaseSync; dbPath: string } { - const dbPath = makeDbPath(); - const db = new Database(dbPath); +/** + * These tests exercise the cleanup SQL, not durability. A fresh on-disk database per + * test pays a schema-creation fsync that is cheap on a local NVMe but slow enough on CI + * runners to blow the 5s per-test timeout, so the database stays in memory. + */ +function createDb(lines: SeedLine[]): { db: DatabaseSync } { + const db = new Database(':memory:'); ensureSchema(db); seed(db, lines); - return { db, dbPath }; + return { db }; } /** A typeset line mpv reported once per animation frame. */ @@ -119,7 +109,7 @@ function wordFrequency(db: DatabaseSync): number { } test('a karaoke burst collapses to one line and gives back its word counts', () => { - const { db, dbPath } = createDb([ + const { db } = createDb([ ...karaokeFrames(1, '飛び上がる', 10_000, 40, 40), { session: 1, text: 'おはよう', startMs: 20_000, endMs: 22_000 }, ]); @@ -148,7 +138,6 @@ test('a karaoke burst collapses to one line and gives back its word counts', () assert.equal(summary.samples[0]!.videoTitle, 'Ep 1'); } finally { db.close(); - cleanupDbPath(dbPath); } }); @@ -160,7 +149,7 @@ test('ordinary repeated dialogue survives', () => { startMs: 5_000 + index * 800, endMs: 5_000 + (index + 1) * 800, })); - const { db, dbPath } = createDb(lines); + const { db } = createDb(lines); try { const summary = cleanupDuplicateSubtitleLines(db); @@ -171,14 +160,13 @@ test('ordinary repeated dialogue survives', () => { assert.equal(wordFrequency(db), 6); } finally { db.close(); - cleanupDbPath(dbPath); } }); test('a long run of quarter-second frames is still a burst', () => { // Between the timing-only bound (0.1s) and the animation-frame bound (0.3s): heavier // typesetting lands here, and the run length is what makes it conclusive. - const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250)); + const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250)); try { const summary = cleanupDuplicateSubtitleLines(db); @@ -189,12 +177,11 @@ test('a long run of quarter-second frames is still a burst', () => { assert.equal(wordFrequency(db), 1); } finally { db.close(); - cleanupDbPath(dbPath); } }); test('a qualifying short-frame burst may end with one long hold frame', () => { - const { db, dbPath } = createDb([ + const { db } = createDb([ ...karaokeFrames(1, '飛び上がる', 10_000, 8, 40), { session: 1, text: '飛び上がる', startMs: 10_320, endMs: 12_320 }, ]); @@ -208,12 +195,11 @@ test('a qualifying short-frame burst may end with one long hold frame', () => { assert.equal(wordFrequency(db), 1); } finally { db.close(); - cleanupDbPath(dbPath); } }); test('a long event before the final frame prevents burst cleanup', () => { - const { db, dbPath } = createDb([ + const { db } = createDb([ ...karaokeFrames(1, '飛び上がる', 10_000, 5, 40), { session: 1, text: '飛び上がる', startMs: 10_200, endMs: 12_200 }, { session: 1, text: '飛び上がる', startMs: 12_200, endMs: 12_240 }, @@ -226,12 +212,11 @@ test('a long event before the final frame prevents burst cleanup', () => { assert.equal(countLines(db), 7); } finally { db.close(); - cleanupDbPath(dbPath); } }); test('a run of frames longer than the animation bound survives', () => { - const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 400)); + const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 400)); try { const summary = cleanupDuplicateSubtitleLines(db); @@ -240,7 +225,6 @@ test('a run of frames longer than the animation bound survives', () => { assert.equal(countLines(db), 6); } finally { db.close(); - cleanupDbPath(dbPath); } }); @@ -248,7 +232,7 @@ test('the four-frame residue the live gate stores is cleaned up', () => { // The streaming gate records the first four frames of a burst before the run is long // enough to recognise. Four contiguous identical events under the strict timing-only // bound are that residue, and no real dialogue. - const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 40)); + const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 40)); try { const summary = cleanupDuplicateSubtitleLines(db); @@ -259,14 +243,13 @@ test('the four-frame residue the live gate stores is cleaned up', () => { assert.equal(wordFrequency(db), 1); } finally { db.close(); - cleanupDbPath(dbPath); } }); test('a four-frame run above the strict frame bound survives', () => { // Long enough per event to be plausible dialogue; only a five-event run may use the // looser animation-frame bound. - const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 250)); + const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 250)); try { const summary = cleanupDuplicateSubtitleLines(db); @@ -275,14 +258,13 @@ test('a four-frame run above the strict frame bound survives', () => { assert.equal(countLines(db), 4); } finally { db.close(); - cleanupDbPath(dbPath); } }); test('an explicit minRunLength raises the bar', () => { // Five quarter-second frames qualify under the defaults; a cautious run asking for six // leaves them alone. Above the strict bound, so the residue rule stays out of it. - const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 5, 250)); + const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 5, 250)); try { const preview = cleanupDuplicateSubtitleLines(db, { dryRun: true }); @@ -293,12 +275,11 @@ test('an explicit minRunLength raises the bar', () => { assert.equal(countLines(db), 5); } finally { db.close(); - cleanupDbPath(dbPath); } }); test('an explicit maxFrameSeconds tightens the frame bound', () => { - const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250)); + const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250)); try { const summary = cleanupDuplicateSubtitleLines(db, { maxFrameSeconds: 0.2 }); @@ -307,13 +288,12 @@ test('an explicit maxFrameSeconds tightens the frame bound', () => { assert.equal(countLines(db), 6); } finally { db.close(); - cleanupDbPath(dbPath); } }); test('a non-finite maxFrameSeconds falls back to the default bound', () => { // Six normal-beat lines: Infinity must not turn every event into a "short frame". - const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 800)); + const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 800)); try { const summary = cleanupDuplicateSubtitleLines(db, { maxFrameSeconds: Infinity }); @@ -322,12 +302,11 @@ test('a non-finite maxFrameSeconds falls back to the default bound', () => { assert.equal(countLines(db), 6); } finally { db.close(); - cleanupDbPath(dbPath); } }); test('sampleLimit zero removes bursts but reports no samples', () => { - const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40)); + const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40)); try { const summary = cleanupDuplicateSubtitleLines(db, { sampleLimit: 0 }); @@ -337,12 +316,11 @@ test('sampleLimit zero removes bursts but reports no samples', () => { assert.equal(countLines(db), 1); } finally { db.close(); - cleanupDbPath(dbPath); } }); test('a short run below every threshold survives', () => { - const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 3, 40)); + const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 3, 40)); try { const summary = cleanupDuplicateSubtitleLines(db); @@ -351,7 +329,6 @@ test('a short run below every threshold survives', () => { assert.equal(countLines(db), 3); } finally { db.close(); - cleanupDbPath(dbPath); } }); @@ -361,7 +338,7 @@ test('interleaved dual-line karaoke collapses each line to one row', () => { const kanji = karaokeFrames(1, '飛び上がる', 10_000, 20, 60); const romaji = karaokeFrames(1, 'tobiagaru', 10_001, 20, 60); const interleaved = [...kanji, ...romaji].sort((a, b) => a.startMs - b.startMs); - const { db, dbPath } = createDb(interleaved); + const { db } = createDb(interleaved); try { const summary = cleanupDuplicateSubtitleLines(db); @@ -372,12 +349,11 @@ test('interleaved dual-line karaoke collapses each line to one row', () => { assert.equal(wordFrequency(db), 2); } finally { db.close(); - cleanupDbPath(dbPath); } }); test('the same line in a rewatch session is never merged into the first watch', () => { - const { db, dbPath } = createDb([ + const { db } = createDb([ ...karaokeFrames(1, '飛び上がる', 10_000, 6, 40), ...karaokeFrames(2, '飛び上がる', 10_000, 6, 40), ]); @@ -392,12 +368,11 @@ test('the same line in a rewatch session is never merged into the first watch', assert.equal(wordFrequency(db), 2); } finally { db.close(); - cleanupDbPath(dbPath); } }); test('a gap between runs splits them', () => { - const { db, dbPath } = createDb([ + const { db } = createDb([ ...karaokeFrames(1, '飛び上がる', 10_000, 6, 40), ...karaokeFrames(1, '飛び上がる', 60_000, 6, 40), ]); @@ -409,12 +384,11 @@ test('a gap between runs splits them', () => { assert.equal(countLines(db), 2); } finally { db.close(); - cleanupDbPath(dbPath); } }); test('a dry run reports what an apply would do and writes nothing', () => { - const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40)); + const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40)); try { const preview = cleanupDuplicateSubtitleLines(db, { dryRun: true }); @@ -430,14 +404,13 @@ test('a dry run reports what an apply would do and writes nothing', () => { assert.equal(countLines(db), 1); } finally { db.close(); - cleanupDbPath(dbPath); } }); test('the lookback window leaves older bursts alone', () => { const recentMs = BASE_MS; const oldMs = BASE_MS - 40 * DAY_MS; - const { db, dbPath } = createDb([ + const { db } = createDb([ ...karaokeFrames(1, '飛び上がる', 10_000, 6, 40).map((line) => ({ ...line, createdMs: oldMs, @@ -462,6 +435,5 @@ test('the lookback window leaves older bursts alone', () => { } finally { globalThis.__subminerTestNowMs = undefined; db.close(); - cleanupDbPath(dbPath); } }); diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts index f1eddcdd..d7fc64e5 100644 --- a/src/core/services/immersion-tracker/__tests__/query.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query.test.ts @@ -31,6 +31,7 @@ import { getKanjiOccurrences, getSessionSummaries, getVocabularyStats, + getVocabularySummary, getKanjiStats, getSessionEvents, getSessionTimeline, @@ -1875,6 +1876,115 @@ test('getVocabularyStats returns rows ordered by frequency descending', () => { } }); +test('getVocabularySummary counts every tracked vocabulary row instead of a display page', () => { + const dbPath = makeDbPath(); + const db = openTestDb(dbPath); + + try { + ensureSchema(db); + const nowSec = Math.floor(Date.now() / 1000); + const insertWord = db.prepare(` + INSERT INTO imm_words ( + headword, word, reading, part_of_speech, pos1, pos2, pos3, + first_seen, last_seen, frequency + ) VALUES (?, ?, '', 'noun', '名詞', '一般', '', ?, ?, 1) + `); + const insertKanji = db.prepare(` + INSERT INTO imm_kanji (kanji, first_seen, last_seen, frequency) + VALUES (?, ?, ?, 1) + `); + + for (let index = 0; index < 501; index += 1) { + insertWord.run(`単語${index}`, `単語${index}`, nowSec - 8 * 86_400, nowSec - 8 * 86_400); + } + for (let index = 0; index < 201; index += 1) { + insertKanji.run( + String.fromCodePoint(0x4e00 + index), + nowSec - 8 * 86_400, + nowSec - 8 * 86_400, + ); + } + insertWord.run('今週', '今週', nowSec - 86_400, nowSec - 86_400); + + assert.deepEqual(getVocabularySummary(db, new Set(['単語0', '今週']), nowSec * 1000), { + uniqueWords: 502, + uniqueWordsWithoutNames: 502, + uniqueKanji: 201, + newThisWeek: 1, + newThisWeekWithoutNames: 1, + knownWordCount: 2, + knownWordCountWithoutNames: 2, + }); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); + +test('getVocabularySummary applies vocabulary exclusions and Hide Names totals', () => { + const dbPath = makeDbPath(); + const db = openTestDb(dbPath); + + try { + ensureSchema(db); + const insertWord = db.prepare(` + INSERT INTO imm_words ( + headword, word, reading, part_of_speech, pos1, pos2, pos3, + first_seen, last_seen, frequency + ) VALUES (?, ?, '', 'noun', '名詞', ?, '', 1, 1, 1) + `); + insertWord.run('猫', '猫', '一般'); + insertWord.run('太郎', '太郎', '固有名詞'); + insertWord.run('東京', '東京都', '一般'); + db.prepare( + ` + INSERT INTO imm_stats_excluded_words (headword, word, reading) + VALUES ('東京', '東京', '') + `, + ).run(); + + assert.deepEqual(getVocabularySummary(db, new Set(['猫', '太郎', '東京']), 9 * 86_400_000), { + uniqueWords: 2, + uniqueWordsWithoutNames: 1, + uniqueKanji: 0, + newThisWeek: 0, + newThisWeekWithoutNames: 0, + knownWordCount: 2, + knownWordCountWithoutNames: 1, + }); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); + +test('getVocabularySummary counts identically across id-keyed scan batches', () => { + const dbPath = makeDbPath(); + const db = openTestDb(dbPath); + + try { + ensureSchema(db); + const insertWord = db.prepare(` + INSERT INTO imm_words ( + headword, word, reading, part_of_speech, pos1, pos2, pos3, + first_seen, last_seen, frequency + ) VALUES (?, ?, '', 'noun', '名詞', '一般', '', 1, 1, 1) + `); + for (let index = 0; index < 5; index += 1) { + insertWord.run(`単語${index}`, `単語${index}`); + } + + const fullScan = getVocabularySummary(db, new Set(['単語0']), 9 * 86_400_000); + const batchedScan = getVocabularySummary(db, new Set(['単語0']), 9 * 86_400_000, 2); + + assert.equal(fullScan.uniqueWords, 5); + assert.deepEqual(batchedScan, fullScan); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); + test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', () => { const dbPath = makeDbPath(); const db = openTestDb(dbPath); diff --git a/src/core/services/immersion-tracker/lexical-rollup-worker-runtime.test.ts b/src/core/services/immersion-tracker/lexical-rollup-worker-runtime.test.ts new file mode 100644 index 00000000..6ae22046 --- /dev/null +++ b/src/core/services/immersion-tracker/lexical-rollup-worker-runtime.test.ts @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + LexicalRollupWorkerRuntime, + resolveLexicalRollupWorkerPath, +} from './lexical-rollup-worker-runtime'; +import { areLexicalDailyRollupsReady } from './lexical-rollups'; +import { Database } from './sqlite'; +import { applyPragmas, ensureSchema } from './storage'; + +test('lexical rollup worker backfills without using the tracker connection', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollup-runtime-')); + const dbPath = path.join(directory, 'immersion.sqlite'); + const runtime = new LexicalRollupWorkerRuntime(); + const db = new Database(dbPath); + + try { + applyPragmas(db); + ensureSchema(db); + db.prepare( + `INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency) + VALUES ('鳥', '鳥', 'とり', 1700000000, 1700000000, 1)`, + ).run(); + db.exec('DELETE FROM imm_lexical_daily_rollups'); + db.prepare(`UPDATE imm_rollup_state SET state_value = '0' WHERE state_key = ?`).run( + 'lexical_daily_rollups_version', + ); + db.close(); + + await runtime.run(dbPath); + + const checkDb = new Database(dbPath); + try { + assert.equal(areLexicalDailyRollupsReady(checkDb), true); + } finally { + checkDb.close(); + } + } finally { + runtime.destroy(); + try { + db.close(); + } catch { + // Closed before the worker starts. + } + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test('lexical rollup worker module resolves in the current layout', () => { + const workerPath = resolveLexicalRollupWorkerPath(); + assert.ok(workerPath, 'expected the lexical rollup worker module to resolve'); + assert.ok(workerPath.endsWith(__filename.endsWith('.ts') ? '.ts' : '.js')); +}); + +test('lexical rollup worker leaves a backfill pending when no worker can start', async () => { + const runtime = new LexicalRollupWorkerRuntime({ + resolveWorkerPath: () => null, + warn: () => {}, + } as never); + + try { + await assert.doesNotReject(runtime.run('/tmp/not-used.sqlite')); + } finally { + runtime.destroy(); + } +}); + +test('lexical rollup worker absorbs termination failures after settling', async () => { + let sendMessage: ((message: { ok: boolean }) => void) | null = null; + const runtime = new LexicalRollupWorkerRuntime({ + resolveWorkerPath: () => '/tmp/fake-worker.js', + createWorker: async () => ({ + once(event: string, listener: (value: never) => void) { + if (event === 'message') sendMessage = listener as (message: { ok: boolean }) => void; + return this; + }, + terminate: async () => { + throw new Error('termination failed'); + }, + }), + warn: () => {}, + } as never); + + const unhandled: unknown[] = []; + const captureUnhandled = (reason: unknown) => unhandled.push(reason); + process.on('unhandledRejection', captureUnhandled); + try { + const task = runtime.run('/tmp/not-used.sqlite'); + await new Promise((resolve) => setImmediate(resolve)); + const notify = sendMessage as ((message: { ok: boolean }) => void) | null; + assert.ok(notify); + notify({ ok: true }); + await task; + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(unhandled, []); + } finally { + process.off('unhandledRejection', captureUnhandled); + runtime.destroy(); + } +}); + +test('lexical rollup worker times out when it never responds', async () => { + let terminated = false; + const runtime = new LexicalRollupWorkerRuntime({ + resolveWorkerPath: () => '/tmp/fake-worker.js', + createWorker: async () => ({ + once() { + return this; + }, + terminate: async () => { + terminated = true; + return 0; + }, + }), + timeoutMs: 1, + warn: () => {}, + } as never); + + try { + const outcome = await Promise.race([ + runtime.run('/tmp/not-used.sqlite').then( + () => 'resolved', + (error: unknown) => String(error), + ), + new Promise((resolve) => setTimeout(() => resolve('still pending'), 50)), + ]); + + assert.match(outcome, /timed out/); + assert.equal(terminated, true); + } finally { + runtime.destroy(); + } +}); diff --git a/src/core/services/immersion-tracker/lexical-rollup-worker-runtime.ts b/src/core/services/immersion-tracker/lexical-rollup-worker-runtime.ts new file mode 100644 index 00000000..30c5c45f --- /dev/null +++ b/src/core/services/immersion-tracker/lexical-rollup-worker-runtime.ts @@ -0,0 +1,117 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createLogger } from '../../../logger'; + +interface WorkerResponse { + ok?: boolean; + error?: unknown; +} + +interface WorkerHandle { + once(event: 'message', listener: (message: WorkerResponse) => void): this; + once(event: 'error', listener: (error: Error) => void): this; + once(event: 'exit', listener: (code: number) => void): this; + terminate(): Promise; +} + +interface LexicalRollupWorkerRuntimeOptions { + resolveWorkerPath?: () => string | null; + createWorker?: (workerPath: string, workerData: { dbPath: string }) => Promise; + timeoutMs?: number; + warn?: (message: string, ...meta: unknown[]) => void; +} + +const logger = createLogger('main:immersion-tracker:lexical-rollup-worker'); +const DEFAULT_WORKER_TIMEOUT_MS = 5 * 60 * 1_000; + +export function resolveLexicalRollupWorkerPath(): string | null { + const fileName = __filename.endsWith('.ts') + ? 'lexical-rollup-worker-thread.ts' + : 'lexical-rollup-worker-thread.js'; + const workerPath = path.join(__dirname, fileName); + return fs.existsSync(workerPath) ? workerPath : null; +} + +export class LexicalRollupWorkerRuntime { + private readonly activeWorkers = new Set(); + private destroyed = false; + + constructor(private readonly options: LexicalRollupWorkerRuntimeOptions = {}) {} + + async run(dbPath: string): Promise { + if (this.destroyed) throw new Error('Lexical rollup worker is shut down'); + let worker: WorkerHandle; + try { + const workerPath = (this.options.resolveWorkerPath ?? resolveLexicalRollupWorkerPath)(); + if (!workerPath) throw new Error('Emitted lexical rollup worker module was not found'); + const createWorker = + this.options.createWorker ?? + (async (resolvedPath, workerData) => { + const { Worker } = await import('node:worker_threads'); + return new Worker(resolvedPath, { workerData }); + }); + worker = await createWorker(workerPath, { dbPath }); + } catch (error) { + if (this.destroyed) throw new Error('Lexical rollup worker is shut down'); + (this.options.warn ?? logger.warn)( + 'Lexical rollup worker unavailable; leaving backfill pending for a later startup', + error, + ); + return; + } + + if (this.destroyed) { + await worker.terminate().catch(() => undefined); + throw new Error('Lexical rollup worker is shut down'); + } + + return new Promise((resolve, reject) => { + let settled = false; + let timeout: ReturnType | null = null; + this.activeWorkers.add(worker); + const settle = (error?: Error) => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + this.activeWorkers.delete(worker); + void worker.terminate().catch(() => undefined); + if (error) reject(error); + else resolve(); + }; + timeout = setTimeout( + () => settle(new Error('Lexical rollup worker timed out')), + this.options.timeoutMs ?? DEFAULT_WORKER_TIMEOUT_MS, + ); + worker.once('message', (message) => { + if (message.ok) settle(); + else + settle( + new Error( + `Lexical rollup backfill failed: ${String(message.error ?? 'unknown error')}`, + ), + ); + }); + worker.once('error', (error) => settle(error)); + worker.once('exit', (code) => { + if (!settled) { + settle( + new Error( + code === 0 + ? 'Lexical rollup worker exited without a response' + : `Lexical rollup worker exited with code ${code}`, + ), + ); + } + }); + }); + } + + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + for (const worker of this.activeWorkers) { + void worker.terminate().catch(() => undefined); + } + this.activeWorkers.clear(); + } +} diff --git a/src/core/services/immersion-tracker/lexical-rollup-worker-thread.ts b/src/core/services/immersion-tracker/lexical-rollup-worker-thread.ts new file mode 100644 index 00000000..9e7cb104 --- /dev/null +++ b/src/core/services/immersion-tracker/lexical-rollup-worker-thread.ts @@ -0,0 +1,11 @@ +import { parentPort, workerData } from 'node:worker_threads'; +import { executeLexicalRollupBackfillTask } from './lexical-rollup-worker'; + +if (!parentPort) throw new Error('lexical rollup worker missing parent port'); + +try { + executeLexicalRollupBackfillTask((workerData as { dbPath: string }).dbPath); + parentPort.postMessage({ ok: true }); +} catch (error) { + parentPort.postMessage({ error: error instanceof Error ? error.message : String(error) }); +} diff --git a/src/core/services/immersion-tracker/lexical-rollup-worker.test.ts b/src/core/services/immersion-tracker/lexical-rollup-worker.test.ts new file mode 100644 index 00000000..ee38f4f1 --- /dev/null +++ b/src/core/services/immersion-tracker/lexical-rollup-worker.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { areLexicalDailyRollupsReady, getLexicalDailyRollups } from './lexical-rollups'; +import { executeLexicalRollupBackfillTask } from './lexical-rollup-worker'; +import { Database } from './sqlite'; +import { ensureSchema } from './storage'; + +test('lexical rollup backfill materializes pre-existing vocabulary off the caller DB connection', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollup-worker-')); + const dbPath = path.join(directory, 'immersion.sqlite'); + const db = new Database(dbPath); + + try { + ensureSchema(db); + db.prepare( + `INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency) + VALUES (?, ?, ?, ?, ?, 1)`, + ).run('犬', '犬', 'いぬ', 1_700_000_000, 1_700_000_000); + db.exec('DELETE FROM imm_lexical_daily_rollups'); + db.prepare(`UPDATE imm_rollup_state SET state_value = '0' WHERE state_key = ?`).run( + 'lexical_daily_rollups_version', + ); + + executeLexicalRollupBackfillTask(dbPath); + + assert.equal(areLexicalDailyRollupsReady(db), true); + assert.equal(getLexicalDailyRollups(db)[0]?.wordCount, 1); + } finally { + db.close(); + fs.rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/src/core/services/immersion-tracker/lexical-rollup-worker.ts b/src/core/services/immersion-tracker/lexical-rollup-worker.ts new file mode 100644 index 00000000..582019ae --- /dev/null +++ b/src/core/services/immersion-tracker/lexical-rollup-worker.ts @@ -0,0 +1,15 @@ +import { areLexicalDailyRollupsReady, rebuildLexicalDailyRollups } from './lexical-rollups'; +import { Database } from './sqlite'; +import { applyPragmas } from './storage'; + +export function executeLexicalRollupBackfillTask(dbPath: string): void { + const db = new Database(dbPath); + try { + applyPragmas(db); + if (!areLexicalDailyRollupsReady(db)) { + rebuildLexicalDailyRollups(db); + } + } finally { + db.close(); + } +} diff --git a/src/core/services/immersion-tracker/lexical-rollups.test.ts b/src/core/services/immersion-tracker/lexical-rollups.test.ts new file mode 100644 index 00000000..8fae14c8 --- /dev/null +++ b/src/core/services/immersion-tracker/lexical-rollups.test.ts @@ -0,0 +1,421 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + areLexicalDailyRollupsReady, + getLexicalDailyRollups, + rebuildLexicalDailyRollups, +} from './lexical-rollups'; +import { getTrendsDashboard } from './query-trends'; +import { + getVocabularyChartData, + getVocabularySummary, + replaceStatsExcludedWords, +} from './query-lexical'; +import { Database } from './sqlite'; +import type { DatabaseSync } from './sqlite'; +import { ensureSchema } from './storage'; + +function makeDbPath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollups-')); + return path.join(dir, 'immersion.sqlite'); +} + +test('lexical daily rollups follow first-seen corrections and deletions', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + const firstDay = 19_500; + const correctedDay = firstDay + 2; + const firstSeen = firstDay * 86_400 + 43_200; + const correctedSeen = correctedDay * 86_400 + 43_200; + + db.prepare( + `INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency) + VALUES (?, ?, ?, ?, ?, 1)`, + ).run('猫', '猫', 'ねこ', firstSeen, firstSeen); + db.prepare( + `INSERT INTO imm_kanji(kanji, first_seen, last_seen, frequency) + VALUES (?, ?, ?, 1)`, + ).run('猫', firstSeen, firstSeen); + + assert.deepEqual(getLexicalDailyRollups(db), [ + { epochDay: firstDay, wordCount: 1, wordCountWithoutNames: 1, kanjiCount: 1 }, + ]); + + db.prepare(`UPDATE imm_words SET first_seen = ? WHERE headword = ?`).run(correctedSeen, '猫'); + db.prepare(`DELETE FROM imm_kanji WHERE kanji = ?`).run('猫'); + + assert.deepEqual(getLexicalDailyRollups(db), [ + { epochDay: correctedDay, wordCount: 1, wordCountWithoutNames: 1, kanjiCount: 0 }, + ]); + } finally { + db.close(); + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + } +}); + +test('lexical daily rollups normalize second and millisecond timestamps', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + const epochDay = 19_500; + const timestampSeconds = epochDay * 86_400 + 43_200; + const timestampMilliseconds = timestampSeconds * 1_000; + + db.prepare( + `INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency) + VALUES (?, ?, ?, ?, ?, 1)`, + ).run('猫', '猫', 'ねこ', timestampSeconds, timestampSeconds); + db.prepare( + `INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency) + VALUES (?, ?, ?, ?, ?, 1)`, + ).run('犬', '犬', 'いぬ', timestampMilliseconds, timestampMilliseconds); + db.prepare( + `INSERT INTO imm_kanji(kanji, first_seen, last_seen, frequency) + VALUES (?, ?, ?, 1)`, + ).run('猫', timestampSeconds, timestampSeconds); + db.prepare( + `INSERT INTO imm_kanji(kanji, first_seen, last_seen, frequency) + VALUES (?, ?, ?, 1)`, + ).run('犬', timestampMilliseconds, timestampMilliseconds); + + assert.deepEqual(getLexicalDailyRollups(db), [ + { epochDay, wordCount: 2, wordCountWithoutNames: 2, kanjiCount: 2 }, + ]); + } finally { + db.close(); + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + } +}); + +test('lexical rollup rebuild excludes rows hidden by vocabulary persistence rules', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + const epochDay = 19_500; + const firstSeen = epochDay * 86_400 + 43_200; + db.prepare( + `INSERT INTO imm_words( + headword, word, reading, part_of_speech, first_seen, last_seen, frequency + ) VALUES (?, ?, ?, ?, ?, ?, 1)`, + ).run('猫', '猫', 'ねこ', 'noun', firstSeen, firstSeen); + db.prepare( + `INSERT INTO imm_words( + headword, word, reading, part_of_speech, first_seen, last_seen, frequency + ) VALUES (?, ?, ?, ?, ?, ?, 1)`, + ).run('は', 'は', 'は', 'particle', firstSeen, firstSeen); + + rebuildLexicalDailyRollups(db); + + assert.deepEqual(getLexicalDailyRollups(db), [ + { epochDay, wordCount: 1, wordCountWithoutNames: 1, kanjiCount: 0 }, + ]); + } finally { + db.close(); + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + } +}); + +test('lexical rollup rebuild tolerates nullable legacy vocabulary text', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + db.prepare( + `INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency) + VALUES (NULL, NULL, NULL, 1700000000, 1700000000, 1)`, + ).run(); + db.prepare( + `INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency) + VALUES (NULL, '猫', 'ねこ', 1700000000, 1700000000, 1)`, + ).run(); + + assert.doesNotThrow(() => rebuildLexicalDailyRollups(db)); + assert.equal(areLexicalDailyRollupsReady(db), true); + assert.equal(getVocabularySummary(db, null).uniqueWords, 1); + assert.equal(getVocabularySummary(db, new Set(['猫'])).knownWordCount, 1); + assert.equal(getVocabularyChartData(db).topWords[0]?.headword, '猫'); + } finally { + db.close(); + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + } +}); + +test('lexical rollup rebuild scans vocabulary visibility in bounded id batches', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + const expectedBatchSize = 5_000; + + try { + ensureSchema(db); + const insertWord = db.prepare( + `INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency) + VALUES (?, ?, '', 1700000000, 1700000000, 1)`, + ); + db.exec('BEGIN'); + for (let index = 0; index <= expectedBatchSize; index += 1) { + insertWord.run(`語${index}`, `語${index}`); + } + db.exec('COMMIT'); + + const scanPageSizes: number[] = []; + const instrumentedDb: DatabaseSync = { + prepare(source) { + const statement = db.prepare(source); + if (!source.includes('WHERE id > ?') || !source.includes('ORDER BY id')) { + return statement; + } + return { + run: (...params) => statement.run(...params), + get: (...params) => statement.get(...params), + all: (...params) => { + const rows = statement.all(...params); + scanPageSizes.push(rows.length); + return rows; + }, + }; + }, + exec(source) { + db.exec(source); + return instrumentedDb; + }, + close() { + return instrumentedDb; + }, + }; + + rebuildLexicalDailyRollups(instrumentedDb); + + assert.deepEqual(scanPageSizes, [expectedBatchSize, 1]); + assert.equal(getVocabularySummary(db, null).uniqueWords, expectedBatchSize + 1); + } finally { + db.close(); + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + } +}); + +test('chart exclusions do not subtract vocabulary rows already hidden from the rollup', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + const epochDay = 19_500; + const firstSeen = epochDay * 86_400 + 43_200; + db.prepare( + `INSERT INTO imm_words( + headword, word, reading, part_of_speech, first_seen, last_seen, frequency + ) VALUES (?, ?, ?, ?, ?, ?, 1)`, + ).run('猫', '猫', 'ねこ', 'noun', firstSeen, firstSeen); + db.prepare( + `INSERT INTO imm_words( + headword, word, reading, part_of_speech, first_seen, last_seen, frequency + ) VALUES (?, ?, ?, ?, ?, ?, 1)`, + ).run('は', 'は', 'は', 'particle', firstSeen, firstSeen); + rebuildLexicalDailyRollups(db); + replaceStatsExcludedWords(db, [{ headword: 'は', word: 'は', reading: 'は' }]); + + const charts = getVocabularyChartData(db); + + assert.deepEqual(charts.newWordsTimeline, [{ epochDay, wordCount: 1 }]); + assert.deepEqual(charts.newWordsTimelineWithoutNames, [{ epochDay, wordCount: 1 }]); + } finally { + db.close(); + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + } +}); + +test('legacy lexical rollup readiness does not satisfy the current rollup version', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + db.prepare( + `INSERT INTO imm_rollup_state(state_key, state_value) + VALUES ('lexical_daily_rollups_ready', '1') + ON CONFLICT(state_key) DO UPDATE SET state_value = excluded.state_value`, + ).run(); + db.prepare( + `DELETE FROM imm_rollup_state WHERE state_key = 'lexical_daily_rollups_version'`, + ).run(); + + assert.equal(areLexicalDailyRollupsReady(db), false); + } finally { + db.close(); + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + } +}); + +test('current lexical rollup readiness accepts legacy integer state storage', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + db.exec(` + CREATE TABLE imm_rollup_state( + state_key TEXT PRIMARY KEY, + state_value INTEGER NOT NULL + ); + INSERT INTO imm_rollup_state(state_key, state_value) + VALUES ('lexical_daily_rollups_version', 2); + `); + + assert.equal(areLexicalDailyRollupsReady(db), true); + } finally { + db.close(); + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + } +}); + +test('imm_words persists vocabulary visibility for rollup maintenance', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + const columns = db.prepare(`PRAGMA table_info(imm_words)`).all() as Array<{ name: string }>; + + assert.equal( + columns.some((column) => column.name === 'vocabulary_visible'), + true, + ); + } finally { + db.close(); + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + } +}); + +test('vocabulary charts use complete top-word and lexical rollup data', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + const insertWord = db.prepare( + `INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency) + VALUES (?, ?, '', 1700000000, 1700000000, ?)`, + ); + for (let index = 0; index < 501; index += 1) { + insertWord.run(`語${index}`, `語${index}`, index === 500 ? 10_000 : 1); + } + + const charts = getVocabularyChartData(db); + + assert.equal(charts.topWords[0]?.headword, '語500'); + assert.equal(charts.topWords[0]?.frequency, 10_000); + assert.equal(charts.newWordsTimeline[0]?.wordCount, 501); + } finally { + db.close(); + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + } +}); + +test('vocabulary charts find full top-word sets beyond excluded and name rows', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + const insertWord = db.prepare( + `INSERT INTO imm_words(headword, word, reading, pos2, first_seen, last_seen, frequency) + VALUES (?, ?, '', ?, 1700000000, 1700000000, ?)`, + ); + const exclusions = []; + for (let index = 0; index < 100; index += 1) { + const headword = `語${index}`; + insertWord.run( + headword, + headword, + index < 80 && index >= 60 ? '固有名詞' : '一般', + 100 - index, + ); + if (index < 60) exclusions.push({ headword, word: headword, reading: '' }); + } + replaceStatsExcludedWords(db, exclusions); + + const charts = getVocabularyChartData(db); + + assert.equal(charts.topWords.length, 12); + assert.equal(charts.topWords[0]?.headword, '語60'); + assert.equal(charts.topWordsWithoutNames.length, 12); + assert.equal(charts.topWordsWithoutNames[0]?.headword, '語80'); + } finally { + db.close(); + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + } +}); + +test('vocabulary charts handle exclusion lists above one SQLite variable batch', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + db.prepare( + `INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency) + VALUES ('語0', '語0', '', 1700000000, 1700000000, 1)`, + ).run(); + const exclusions = Array.from({ length: 10_923 }, (_, index) => ({ + headword: `語${index}`, + word: `語${index}`, + reading: '', + })); + replaceStatsExcludedWords(db, exclusions); + + const charts = getVocabularyChartData(db); + + assert.deepEqual(charts.topWords, []); + assert.deepEqual(charts.newWordsTimeline, []); + } finally { + db.close(); + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + } +}); + +test('lexical rollup rebuild preserves the original error when rollback also fails', () => { + const originalError = new Error('rebuild failed'); + const db = { + exec(sql: string) { + if (sql === 'BEGIN IMMEDIATE') return; + if (sql === 'ROLLBACK') throw new Error('rollback failed'); + throw originalError; + }, + prepare() { + return { all: () => [], run: () => undefined }; + }, + } as unknown as DatabaseSync; + + assert.throws(() => rebuildLexicalDailyRollups(db), originalError); +}); + +test('trends read historical new-word buckets from lexical rollups', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + db.prepare( + `INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency) + VALUES ('海', '海', 'うみ', 1700000000, 1700000000, 1)`, + ).run(); + db.prepare(`UPDATE imm_lexical_daily_rollups SET word_count = 9`).run(); + + const dashboard = getTrendsDashboard(db, 'all', 'day', false); + + assert.equal(dashboard.progress.newWords[0]?.value, 9); + } finally { + db.close(); + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + } +}); diff --git a/src/core/services/immersion-tracker/lexical-rollups.ts b/src/core/services/immersion-tracker/lexical-rollups.ts new file mode 100644 index 00000000..ea5480a9 --- /dev/null +++ b/src/core/services/immersion-tracker/lexical-rollups.ts @@ -0,0 +1,229 @@ +import type { DatabaseSync } from './sqlite'; +import { isVocabularyStatsRowVisible, type VocabularyVisibilityRow } from './vocabulary-visibility'; + +export interface LexicalDailyRollup { + epochDay: number; + wordCount: number; + wordCountWithoutNames: number; + kanjiCount: number; +} + +const LOCAL_EPOCH_DAY_SQL = ` + CAST(julianday( + CASE + WHEN ABS(CAST(%VALUE% AS REAL)) >= 10000000000 THEN CAST(%VALUE% AS REAL) / 1000 + ELSE CAST(%VALUE% AS REAL) + END, + 'unixepoch', 'localtime' + ) - 2440587.5 AS INTEGER) +`; + +const LEXICAL_DAILY_ROLLUP_VERSION = '2'; +const LEXICAL_DAILY_ROLLUP_VERSION_KEY = 'lexical_daily_rollups_version'; +const VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE = 5_000; + +export function localEpochDaySql(value: string): string { + return LOCAL_EPOCH_DAY_SQL.replaceAll('%VALUE%', value); +} + +function createWordRollupTriggers(db: DatabaseSync): void { + const dayForNew = localEpochDaySql('NEW.first_seen'); + const dayForOld = localEpochDaySql('OLD.first_seen'); + + db.exec(` + DROP TRIGGER IF EXISTS imm_words_lexical_rollup_insert; + DROP TRIGGER IF EXISTS imm_words_lexical_rollup_delete; + DROP TRIGGER IF EXISTS imm_words_lexical_rollup_first_seen_update; + + CREATE TRIGGER imm_words_lexical_rollup_insert + AFTER INSERT ON imm_words + WHEN NEW.first_seen IS NOT NULL AND NEW.vocabulary_visible = 1 + BEGIN + INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count) + VALUES (${dayForNew}, 1, CASE WHEN NEW.pos2 = '固有名詞' THEN 0 ELSE 1 END, 0) + ON CONFLICT(epoch_day) DO UPDATE SET + word_count = word_count + 1, + word_count_without_names = word_count_without_names + excluded.word_count_without_names; + END; + + CREATE TRIGGER imm_words_lexical_rollup_delete + AFTER DELETE ON imm_words + WHEN OLD.first_seen IS NOT NULL AND OLD.vocabulary_visible = 1 + BEGIN + INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count) + VALUES (${dayForOld}, -1, CASE WHEN OLD.pos2 = '固有名詞' THEN 0 ELSE -1 END, 0) + ON CONFLICT(epoch_day) DO UPDATE SET + word_count = word_count - 1, + word_count_without_names = word_count_without_names + excluded.word_count_without_names; + DELETE FROM imm_lexical_daily_rollups + WHERE epoch_day = ${dayForOld} AND word_count = 0 AND kanji_count = 0; + END; + + CREATE TRIGGER imm_words_lexical_rollup_first_seen_update + AFTER UPDATE OF first_seen, pos2, vocabulary_visible ON imm_words + WHEN OLD.first_seen IS NOT NEW.first_seen + OR OLD.pos2 IS NOT NEW.pos2 + OR OLD.vocabulary_visible IS NOT NEW.vocabulary_visible + BEGIN + INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count) + SELECT ${dayForOld}, -1, CASE WHEN OLD.pos2 = '固有名詞' THEN 0 ELSE -1 END, 0 + WHERE OLD.first_seen IS NOT NULL AND OLD.vocabulary_visible = 1 + ON CONFLICT(epoch_day) DO UPDATE SET + word_count = word_count - 1, + word_count_without_names = word_count_without_names + excluded.word_count_without_names; + INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count) + SELECT ${dayForNew}, 1, CASE WHEN NEW.pos2 = '固有名詞' THEN 0 ELSE 1 END, 0 + WHERE NEW.first_seen IS NOT NULL AND NEW.vocabulary_visible = 1 + ON CONFLICT(epoch_day) DO UPDATE SET + word_count = word_count + 1, + word_count_without_names = word_count_without_names + excluded.word_count_without_names; + DELETE FROM imm_lexical_daily_rollups + WHERE word_count = 0 AND kanji_count = 0; + END; + `); +} + +function createKanjiRollupTriggers(db: DatabaseSync): void { + const dayForNew = localEpochDaySql('NEW.first_seen'); + const dayForOld = localEpochDaySql('OLD.first_seen'); + db.exec(` + DROP TRIGGER IF EXISTS imm_kanji_lexical_rollup_insert; + DROP TRIGGER IF EXISTS imm_kanji_lexical_rollup_delete; + DROP TRIGGER IF EXISTS imm_kanji_lexical_rollup_first_seen_update; + + CREATE TRIGGER imm_kanji_lexical_rollup_insert + AFTER INSERT ON imm_kanji WHEN NEW.first_seen IS NOT NULL + BEGIN + INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count) + VALUES (${dayForNew}, 0, 0, 1) + ON CONFLICT(epoch_day) DO UPDATE SET kanji_count = kanji_count + 1; + END; + CREATE TRIGGER imm_kanji_lexical_rollup_delete + AFTER DELETE ON imm_kanji WHEN OLD.first_seen IS NOT NULL + BEGIN + INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count) + VALUES (${dayForOld}, 0, 0, -1) + ON CONFLICT(epoch_day) DO UPDATE SET kanji_count = kanji_count - 1; + DELETE FROM imm_lexical_daily_rollups + WHERE epoch_day = ${dayForOld} AND word_count = 0 AND kanji_count = 0; + END; + CREATE TRIGGER imm_kanji_lexical_rollup_first_seen_update + AFTER UPDATE OF first_seen ON imm_kanji WHEN OLD.first_seen IS NOT NEW.first_seen + BEGIN + INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count) + SELECT ${dayForOld}, 0, 0, -1 WHERE OLD.first_seen IS NOT NULL + ON CONFLICT(epoch_day) DO UPDATE SET kanji_count = kanji_count - 1; + INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count) + SELECT ${dayForNew}, 0, 0, 1 WHERE NEW.first_seen IS NOT NULL + ON CONFLICT(epoch_day) DO UPDATE SET kanji_count = kanji_count + 1; + DELETE FROM imm_lexical_daily_rollups WHERE word_count = 0 AND kanji_count = 0; + END; + `); +} + +export function ensureLexicalDailyRollupTables(db: DatabaseSync): void { + db.exec(` + CREATE TABLE IF NOT EXISTS imm_lexical_daily_rollups( + epoch_day INTEGER PRIMARY KEY, + word_count INTEGER NOT NULL DEFAULT 0, + word_count_without_names INTEGER NOT NULL DEFAULT 0, + kanji_count INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO imm_rollup_state(state_key, state_value) + VALUES ('${LEXICAL_DAILY_ROLLUP_VERSION_KEY}', '0') + ON CONFLICT(state_key) DO NOTHING; + `); + createWordRollupTriggers(db); + createKanjiRollupTriggers(db); +} + +export function areLexicalDailyRollupsReady(db: DatabaseSync): boolean { + const row = db + .prepare(`SELECT state_value AS value FROM imm_rollup_state WHERE state_key = ?`) + .get(LEXICAL_DAILY_ROLLUP_VERSION_KEY) as { value: string | number } | undefined; + // Older databases created this column with INTEGER affinity, while current + // databases use TEXT. SQLite returns the same persisted version with a + // different JS type depending on that legacy schema. + return row !== undefined && String(row.value) === LEXICAL_DAILY_ROLLUP_VERSION; +} + +export function markLexicalDailyRollupsReady(db: DatabaseSync): void { + db.prepare( + `INSERT INTO imm_rollup_state(state_key, state_value) + VALUES (?, ?) + ON CONFLICT(state_key) DO UPDATE SET state_value = excluded.state_value`, + ).run(LEXICAL_DAILY_ROLLUP_VERSION_KEY, LEXICAL_DAILY_ROLLUP_VERSION); +} + +/** Rebuild from the first-seen source of truth; run off the UI/main DB thread. */ +export function rebuildLexicalDailyRollups(db: DatabaseSync): void { + let transactionStarted = false; + try { + db.exec('BEGIN IMMEDIATE'); + transactionStarted = true; + const scanVocabulary = db.prepare( + `SELECT id, word, headword, reading, part_of_speech AS partOfSpeech, + pos1, pos2, pos3, frequency_rank AS frequencyRank + FROM imm_words + WHERE id > ? + ORDER BY id + LIMIT ?`, + ); + const updateVisibility = db.prepare( + `UPDATE imm_words SET vocabulary_visible = ? WHERE id = ? AND vocabulary_visible IS NOT ?`, + ); + let lastId = Number.MIN_SAFE_INTEGER; + for (;;) { + const vocabularyRows = scanVocabulary.all( + lastId, + VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE, + ) as Array; + if (vocabularyRows.length === 0) break; + for (const row of vocabularyRows) { + const visible = isVocabularyStatsRowVisible(row) ? 1 : 0; + updateVisibility.run(visible, row.id, visible); + } + lastId = vocabularyRows[vocabularyRows.length - 1]!.id; + if (vocabularyRows.length < VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE) break; + } + db.exec('DELETE FROM imm_lexical_daily_rollups'); + db.exec(` + INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count) + SELECT ${localEpochDaySql('first_seen')}, COUNT(*), + SUM(CASE WHEN pos2 = '固有名詞' THEN 0 ELSE 1 END), 0 + FROM imm_words + WHERE first_seen IS NOT NULL AND vocabulary_visible = 1 + GROUP BY ${localEpochDaySql('first_seen')}; + INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count) + SELECT ${localEpochDaySql('first_seen')}, 0, 0, COUNT(*) + FROM imm_kanji + WHERE first_seen IS NOT NULL + GROUP BY ${localEpochDaySql('first_seen')} + ON CONFLICT(epoch_day) DO UPDATE SET kanji_count = kanji_count + excluded.kanji_count; + `); + markLexicalDailyRollupsReady(db); + db.exec('COMMIT'); + } catch (error) { + if (transactionStarted) { + try { + db.exec('ROLLBACK'); + } catch { + // Preserve the rebuild failure; it is the actionable cause. + } + } + throw error; + } +} + +export function getLexicalDailyRollups(db: DatabaseSync): LexicalDailyRollup[] { + return db + .prepare( + ` + SELECT epoch_day AS epochDay, word_count AS wordCount, + word_count_without_names AS wordCountWithoutNames, kanji_count AS kanjiCount + FROM imm_lexical_daily_rollups + ORDER BY epoch_day ASC + `, + ) + .all() as LexicalDailyRollup[]; +} diff --git a/src/core/services/immersion-tracker/query-lexical.ts b/src/core/services/immersion-tracker/query-lexical.ts index 13533158..2f7bf553 100644 --- a/src/core/services/immersion-tracker/query-lexical.ts +++ b/src/core/services/immersion-tracker/query-lexical.ts @@ -1,6 +1,4 @@ import type { DatabaseSync } from './sqlite'; -import { PartOfSpeech, type MergedToken } from '../../../types'; -import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage'; import type { KanjiAnimeAppearanceRow, KanjiDetailRow, @@ -13,19 +11,38 @@ import type { SimilarWordRow, StatsExcludedWordRow, VocabularyStatsRow, + VocabularyStatsSummary, WordAnimeAppearanceRow, WordDetailRow, WordOccurrenceRow, } from './types'; import { fromDbTimestamp, toDbTimestamp } from './query-shared'; import { nowMs } from './time'; +import { + areLexicalDailyRollupsReady, + getLexicalDailyRollups, + localEpochDaySql, +} from './lexical-rollups'; +import { isVocabularyStatsRowVisible } from './vocabulary-visibility'; const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4; const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100; +const VOCABULARY_CHART_LIMIT = 12; +const VOCABULARY_CHART_PAGE_SIZE = 100; +const EXCLUSION_ALIAS_BATCH_SIZE = 300; +const VOCABULARY_SUMMARY_SCAN_BATCH_SIZE = 5_000; const SENTENCE_SEARCH_DEFAULT_LIMIT = 50; const SENTENCE_SEARCH_MAX_LIMIT = 100; const KANJI_PATTERN = /\p{Script=Han}/gu; +export interface VocabularyChartData { + ready: boolean; + topWords: Array<{ wordId: number; headword: string; frequency: number }>; + topWordsWithoutNames: Array<{ wordId: number; headword: string; frequency: number }>; + newWordsTimeline: Array<{ epochDay: number; wordCount: number }>; + newWordsTimelineWithoutNames: Array<{ epochDay: number; wordCount: number }>; +} + function resolveSentenceSearchLimit(limit: number): number { if (!Number.isFinite(limit)) return SENTENCE_SEARCH_DEFAULT_LIMIT; const normalized = Math.floor(limit); @@ -73,33 +90,6 @@ function uniqueKanji(text: string): string[] { return Array.from(new Set(text.match(KANJI_PATTERN) ?? [])); } -function toVocabularyToken(row: VocabularyStatsRow): MergedToken { - const partOfSpeech = - row.partOfSpeech && Object.values(PartOfSpeech).includes(row.partOfSpeech as PartOfSpeech) - ? (row.partOfSpeech as PartOfSpeech) - : PartOfSpeech.other; - - return { - surface: row.word, - reading: row.reading ?? '', - headword: row.headword, - startPos: 0, - endPos: row.word.length, - partOfSpeech, - pos1: row.pos1 ?? '', - pos2: row.pos2 ?? '', - pos3: row.pos3 ?? '', - frequencyRank: row.frequencyRank ?? undefined, - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }; -} - -function isVocabularyStatsRowVisible(row: VocabularyStatsRow): boolean { - return !shouldExcludeTokenFromVocabularyPersistence(toVocabularyToken(row)); -} - export function getVocabularyStats( db: DatabaseSync, limit = 100, @@ -153,6 +143,198 @@ export function getVocabularyStats( return visibleRows.slice(0, limit); } +/** + * Chart data is intentionally independent of the paginated vocabulary tables. + * Top words use the frequency index; new-word history reads permanent daily + * lexical rollups rather than loading every vocabulary row into the dashboard. + */ +export function getVocabularyChartData(db: DatabaseSync): VocabularyChartData { + const ready = areLexicalDailyRollupsReady(db); + const excludedAliases = new Set( + getStatsExcludedWords(db).flatMap((word) => excludedVocabularyAliases(word)), + ); + const isExcluded = (word: Pick): boolean => + excludedVocabularyAliases(word).some((alias) => excludedAliases.has(alias)); + const topWords = getTopVocabularyChartWords(db, isExcluded); + const rollups = ready ? getLexicalDailyRollups(db) : []; + const timeline = new Map(rollups.map((row) => [row.epochDay, { ...row }])); + if (excludedAliases.size > 0 && ready) { + const aliases = [...excludedAliases]; + const excludedRows = new Map< + number, + Pick & { + wordId: number; + epochDay: number; + } + >(); + for (let offset = 0; offset < aliases.length; offset += EXCLUSION_ALIAS_BATCH_SIZE) { + const batch = aliases.slice(offset, offset + EXCLUSION_ALIAS_BATCH_SIZE); + const placeholders = batch.map(() => '?').join(', '); + const rows = db + .prepare( + ` + SELECT id AS wordId, headword, word, reading, pos2, + ${localEpochDaySql('first_seen')} AS epochDay + FROM imm_words + WHERE vocabulary_visible = 1 + AND (headword IN (${placeholders}) OR word IN (${placeholders}) OR reading IN (${placeholders})) + `, + ) + .all(...batch, ...batch, ...batch) as Array< + Pick & { + wordId: number; + epochDay: number; + } + >; + for (const row of rows) excludedRows.set(row.wordId, row); + } + for (const word of excludedRows.values()) { + if (!isExcluded(word)) continue; + const rollup = timeline.get(word.epochDay); + if (!rollup) continue; + rollup.wordCount -= 1; + if (word.pos2 !== '固有名詞') rollup.wordCountWithoutNames -= 1; + } + } + return { + ready, + topWords: topWords.all.map((word) => ({ + wordId: word.wordId, + headword: vocabularyDisplayHeadword(word), + frequency: word.frequency, + })), + topWordsWithoutNames: topWords.withoutNames.map((word) => ({ + wordId: word.wordId, + headword: vocabularyDisplayHeadword(word), + frequency: word.frequency, + })), + newWordsTimeline: [...timeline.values()] + .filter((row) => row.wordCount > 0) + .map((row) => ({ epochDay: row.epochDay, wordCount: row.wordCount })), + newWordsTimelineWithoutNames: [...timeline.values()] + .filter((row) => row.wordCountWithoutNames > 0) + .map((row) => ({ epochDay: row.epochDay, wordCount: row.wordCountWithoutNames })), + }; +} + +function getTopVocabularyChartWords( + db: DatabaseSync, + isExcluded: (word: Pick) => boolean, +): { all: VocabularyStatsRow[]; withoutNames: VocabularyStatsRow[] } { + const stmt = db.prepare(` + SELECT id AS wordId, headword, word, reading, + part_of_speech AS partOfSpeech, pos1, pos2, pos3, + frequency, frequency_rank AS frequencyRank, + first_seen AS firstSeen, last_seen AS lastSeen, + 0 AS animeCount + FROM imm_words + ORDER BY frequency DESC, id + LIMIT ? OFFSET ? + `); + const all: VocabularyStatsRow[] = []; + const withoutNames: VocabularyStatsRow[] = []; + let offset = 0; + + while (all.length < VOCABULARY_CHART_LIMIT || withoutNames.length < VOCABULARY_CHART_LIMIT) { + const page = stmt.all(VOCABULARY_CHART_PAGE_SIZE, offset) as VocabularyStatsRow[]; + if (page.length === 0) break; + for (const word of page) { + if (!isVocabularyStatsRowVisible(word) || isExcluded(word)) continue; + if (all.length < VOCABULARY_CHART_LIMIT) all.push(word); + if (word.pos2 !== '固有名詞' && withoutNames.length < VOCABULARY_CHART_LIMIT) { + withoutNames.push(word); + } + } + offset += page.length; + } + + return { all, withoutNames }; +} + +function excludedVocabularyAliases( + word: Pick, +): string[] { + const aliases = [word.headword?.trim() ?? '', word.word?.trim() ?? ''].filter(Boolean); + if (aliases.length === 0) aliases.push(word.reading?.trim() ?? ''); + return [...new Set(aliases)]; +} + +function vocabularyDisplayHeadword( + word: Pick, +): string { + return word.headword?.trim() || word.word?.trim() || word.reading?.trim() || ''; +} + +function timestampSeconds(timestamp: number): number { + return timestamp < 10_000_000_000 ? timestamp : Math.floor(timestamp / 1000); +} + +export function getVocabularySummary( + db: DatabaseSync, + knownWords: ReadonlySet | null, + nowMs: number = Date.now(), + scanBatchSize: number = VOCABULARY_SUMMARY_SCAN_BATCH_SIZE, +): VocabularyStatsSummary { + // Visibility and exclusion rules live in JS, so rows are scanned in id-keyed + // batches to keep memory bounded on large vocabularies. + const scanStmt = db.prepare(` + SELECT id AS wordId, headword, word, reading, + part_of_speech AS partOfSpeech, pos1, pos2, pos3, + frequency, frequency_rank AS frequencyRank, + first_seen AS firstSeen, last_seen AS lastSeen, + 0 AS animeCount + FROM imm_words + WHERE id > ? + ORDER BY id + LIMIT ? + `); + const excludedAliases = new Set( + getStatsExcludedWords(db).flatMap((word) => excludedVocabularyAliases(word)), + ); + const weekAgoSec = nowMs / 1000 - 7 * 86_400; + const summary: VocabularyStatsSummary = { + uniqueWords: 0, + uniqueWordsWithoutNames: 0, + uniqueKanji: (db.prepare('SELECT COUNT(*) AS count FROM imm_kanji').get() as { count: number }) + .count, + newThisWeek: 0, + newThisWeekWithoutNames: 0, + knownWordCount: knownWords ? 0 : null, + knownWordCountWithoutNames: knownWords ? 0 : null, + }; + + let lastId = Number.MIN_SAFE_INTEGER; + for (;;) { + const words = scanStmt.all(lastId, scanBatchSize) as VocabularyStatsRow[]; + if (words.length === 0) break; + lastId = words[words.length - 1]!.wordId; + for (const word of words) { + if ( + !isVocabularyStatsRowVisible(word) || + excludedVocabularyAliases(word).some((alias) => excludedAliases.has(alias)) + ) { + continue; + } + const isName = word.pos2 === '固有名詞'; + const isNewThisWeek = timestampSeconds(fromDbTimestamp(word.firstSeen) ?? 0) >= weekAgoSec; + const isKnown = knownWords?.has(vocabularyDisplayHeadword(word)) ?? false; + summary.uniqueWords += 1; + if (!isName) summary.uniqueWordsWithoutNames += 1; + if (isNewThisWeek) { + summary.newThisWeek += 1; + if (!isName) summary.newThisWeekWithoutNames += 1; + } + if (isKnown) { + summary.knownWordCount! += 1; + if (!isName) summary.knownWordCountWithoutNames! += 1; + } + } + if (words.length < scanBatchSize) break; + } + + return summary; +} + export function getStatsExcludedWords(db: DatabaseSync): StatsExcludedWordRow[] { return db .prepare( diff --git a/src/core/services/immersion-tracker/query-trends.ts b/src/core/services/immersion-tracker/query-trends.ts index b0cf0ef5..dfa3c3e2 100644 --- a/src/core/services/immersion-tracker/query-trends.ts +++ b/src/core/services/immersion-tracker/query-trends.ts @@ -13,6 +13,7 @@ import { toDbTimestamp, } from './query-shared'; import { getDailyRollups, getMonthlyRollups } from './query-sessions'; +import { areLexicalDailyRollupsReady, getLexicalDailyRollups } from './lexical-rollups'; type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all'; type TrendGroupBy = 'day' | 'month'; @@ -660,6 +661,16 @@ function buildNewWordsPerDay( cutoffMs: string | null, axis: number[] | null, ): TrendChartPoint[] { + if (areLexicalDailyRollupsReady(db)) { + // A trend range is defined in calendar buckets, so the rollup includes the + // complete local cutoff day rather than applying a time-of-day boundary. + const cutoffDay = cutoffMs === null ? null : getLocalEpochDay(db, cutoffMs); + const rows = getLexicalDailyRollups(db).filter( + (row) => cutoffDay === null || row.epochDay >= cutoffDay, + ); + return fillAxisPoints(axis, new Map(rows.map((row) => [row.epochDay, row.wordCount]))); + } + const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?'; const prepared = db.prepare(` SELECT @@ -691,6 +702,18 @@ function buildNewWordsPerMonth( cutoffMs: string | null, axis: number[] | null, ): TrendChartPoint[] { + if (areLexicalDailyRollupsReady(db)) { + const cutoffDay = cutoffMs === null ? null : getLocalEpochDay(db, cutoffMs); + const byMonth = new Map(); + for (const row of getLexicalDailyRollups(db)) { + if (cutoffDay !== null && row.epochDay < cutoffDay) continue; + const { year, month } = dayPartsFromEpochDay(row.epochDay); + const monthKey = year * 100 + month; + byMonth.set(monthKey, (byMonth.get(monthKey) ?? 0) + row.wordCount); + } + return fillAxisPoints(axis, byMonth); + } + const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?'; const prepared = db.prepare(` SELECT diff --git a/src/core/services/immersion-tracker/storage-session.test.ts b/src/core/services/immersion-tracker/storage-session.test.ts index 9bb718d4..ff36deb8 100644 --- a/src/core/services/immersion-tracker/storage-session.test.ts +++ b/src/core/services/immersion-tracker/storage-session.test.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import { Database } from './sqlite'; +import { Database, type DatabaseSync } from './sqlite'; import { getStatsExcludedWords, replaceStatsExcludedWords } from './query-lexical'; import { finalizeSessionRecord, startSessionRecord } from './session'; import { @@ -87,6 +87,29 @@ test('applyPragmas sets the SQLite tuning defaults used by immersion tracking', } }); +test('applyPragmas installs the busy timeout before WAL negotiation', () => { + const statements: string[] = []; + const db: DatabaseSync = { + exec(source) { + statements.push(source); + return db; + }, + prepare() { + throw new Error('not used'); + }, + close() { + return db; + }, + }; + + applyPragmas(db); + + assert.deepEqual(statements.slice(0, 2), [ + 'PRAGMA busy_timeout = 2500', + 'PRAGMA journal_mode = WAL', + ]); +}); + test('ensureSchema creates immersion core tables', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); @@ -184,6 +207,51 @@ test('ensureSchema adds manual assignment locks when upgrading the previous sche } }); +test('ensureSchema preserves durable session rollups across unrelated schema upgrades', () => { + const dbPath = makeDbPath(); + const db = new Database(dbPath); + + try { + ensureSchema(db); + db.exec(` + INSERT INTO imm_videos ( + video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE + ) VALUES (1, 'local:/tmp/preserved.mkv', 'Preserved', 1, 0, '1', '1'); + INSERT INTO imm_daily_rollups ( + rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, + total_tokens_seen, total_cards + ) VALUES (20000, 1, 2, 30, 40, 50, 3); + INSERT INTO imm_monthly_rollups ( + rollup_month, video_id, total_sessions, total_active_min, total_lines_seen, + total_tokens_seen, total_cards + ) VALUES (202410, 1, 2, 30, 40, 50, 3); + UPDATE imm_rollup_state + SET state_value = '123' + WHERE state_key = 'last_rollup_sample_ms'; + UPDATE imm_schema_version SET schema_version = 21; + `); + + ensureSchema(db); + + const daily = db + .prepare('SELECT total_sessions AS totalSessions FROM imm_daily_rollups') + .get() as { totalSessions: number } | null; + const monthly = db + .prepare('SELECT total_sessions AS totalSessions FROM imm_monthly_rollups') + .get() as { totalSessions: number } | null; + const rollupState = db + .prepare(`SELECT state_value AS value FROM imm_rollup_state WHERE state_key = ?`) + .get('last_rollup_sample_ms') as { value: string } | null; + + assert.equal(daily?.totalSessions, 2); + assert.equal(monthly?.totalSessions, 2); + assert.equal(rollupState?.value, '123'); + } finally { + db.close(); + cleanupDbPath(dbPath); + } +}); + test('stats excluded words are replaced and read from sqlite storage', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts index 77a33986..d04b3cbd 100644 --- a/src/core/services/immersion-tracker/storage.ts +++ b/src/core/services/immersion-tracker/storage.ts @@ -4,6 +4,7 @@ import { parseMediaInfo } from '../../../jimaku/utils'; import { normalizeTitleIdentity } from '../../utils/title-normalization'; import type { DatabaseSync } from './sqlite'; import { nowMs } from './time'; +import { ensureLexicalDailyRollupTables, markLexicalDailyRollupsReady } from './lexical-rollups'; import { SCHEMA_VERSION } from './types'; import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types'; import { toDbMs, toDbTimestamp } from './query-shared'; @@ -314,10 +315,12 @@ function migrateSessionEventTimestampsToText(db: DatabaseSync): void { } export function applyPragmas(db: DatabaseSync): void { + // Install the wait policy before WAL negotiation, which can briefly contend with + // another connection closing or checkpointing the same database. + db.exec('PRAGMA busy_timeout = 2500'); db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA synchronous = NORMAL'); db.exec('PRAGMA foreign_keys = ON'); - db.exec('PRAGMA busy_timeout = 2500'); db.exec(`PRAGMA journal_size_limit = ${WAL_JOURNAL_SIZE_LIMIT_BYTES}`); } @@ -890,11 +893,11 @@ export function ensureSchema(db: DatabaseSync): void { VALUES ('last_rollup_sample_ms', 0) ON CONFLICT(state_key) DO NOTHING `); - const currentVersion = db .prepare('SELECT schema_version FROM imm_schema_version ORDER BY schema_version DESC LIMIT 1') .get() as { schema_version: number } | null; if (currentVersion?.schema_version === SCHEMA_VERSION) { + ensureLexicalDailyRollupTables(db); ensureLifetimeSummaryTables(db); ensureStatsExcludedWordsTable(db); ensureAnimeMergeTables(db); @@ -1068,6 +1071,7 @@ export function ensureSchema(db: DatabaseSync): void { last_seen REAL, frequency INTEGER, frequency_rank INTEGER, + vocabulary_visible INTEGER NOT NULL DEFAULT 1 CHECK(vocabulary_visible IN (0, 1)), UNIQUE(headword, word, reading) ); `); @@ -1451,8 +1455,18 @@ export function ensureSchema(db: DatabaseSync): void { addColumnIfMissing(db, 'imm_sessions', 'ended_media_ms', 'INTEGER'); } + if (currentVersion?.schema_version && currentVersion.schema_version < 23) { + addColumnIfMissing( + db, + 'imm_words', + 'vocabulary_visible', + 'INTEGER NOT NULL DEFAULT 1 CHECK(vocabulary_visible IN (0, 1))', + ); + } + migrateSessionEventTimestampsToText(db); + ensureLexicalDailyRollupTables(db); ensureLifetimeSummaryTables(db); ensureStatsExcludedWordsTable(db); @@ -1572,19 +1586,21 @@ export function ensureSchema(db: DatabaseSync): void { ON imm_youtube_videos(youtube_video_id) `); - if (currentVersion?.schema_version && currentVersion.schema_version < SCHEMA_VERSION) { - db.exec('DELETE FROM imm_daily_rollups'); - db.exec('DELETE FROM imm_monthly_rollups'); - db.exec( - `UPDATE imm_rollup_state SET state_value = 0 WHERE state_key = 'last_rollup_sample_ms'`, - ); - } + // Session rollups intentionally outlive raw session and telemetry retention. + // Preserve them across unrelated schema upgrades because deleted historical + // buckets cannot be rebuilt after their source rows have been pruned. db.exec(` INSERT INTO imm_schema_version(schema_version, applied_at_ms) VALUES (${SCHEMA_VERSION}, ${toDbTimestamp(nowMs())}) ON CONFLICT DO NOTHING `); + + // A new database has no history to materialize. Upgrades are populated by the + // background worker so startup never scans the existing vocabulary table. + if (!currentVersion) { + markLexicalDailyRollupsReady(db); + } } export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPreparedStatements { @@ -1617,9 +1633,10 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar `), wordUpsertStmt: db.prepare(` INSERT INTO imm_words ( - headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency, frequency_rank + headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, + frequency, frequency_rank, vocabulary_visible ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, 1 ) ON CONFLICT(headword, word, reading) DO UPDATE SET frequency = COALESCE(frequency, 0) + 1, @@ -1632,6 +1649,7 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar pos1 = COALESCE(NULLIF(imm_words.pos1, ''), excluded.pos1), pos2 = COALESCE(NULLIF(imm_words.pos2, ''), excluded.pos2), pos3 = COALESCE(NULLIF(imm_words.pos3, ''), excluded.pos3), + vocabulary_visible = 1, first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen), last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen), frequency_rank = CASE diff --git a/src/core/services/immersion-tracker/types.ts b/src/core/services/immersion-tracker/types.ts index faabb4d4..18d3d59a 100644 --- a/src/core/services/immersion-tracker/types.ts +++ b/src/core/services/immersion-tracker/types.ts @@ -1,4 +1,4 @@ -export const SCHEMA_VERSION = 21; +export const SCHEMA_VERSION = 23; export const DEFAULT_QUEUE_CAP = 1_000; export const DEFAULT_BATCH_SIZE = 25; export const DEFAULT_FLUSH_INTERVAL_MS = 500; @@ -306,6 +306,16 @@ export interface VocabularyStatsRow { lastSeen: number; } +export interface VocabularyStatsSummary { + uniqueWords: number; + uniqueWordsWithoutNames: number; + uniqueKanji: number; + newThisWeek: number; + newThisWeekWithoutNames: number; + knownWordCount: number | null; + knownWordCountWithoutNames: number | null; +} + export interface StatsExcludedWordRow { headword: string; word: string; diff --git a/src/core/services/immersion-tracker/vocabulary-summary-worker-runtime.test.ts b/src/core/services/immersion-tracker/vocabulary-summary-worker-runtime.test.ts new file mode 100644 index 00000000..ce465534 --- /dev/null +++ b/src/core/services/immersion-tracker/vocabulary-summary-worker-runtime.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + resolveVocabularySummaryWorkerPath, + VocabularySummaryWorkerRuntime, +} from './vocabulary-summary-worker-runtime'; +import { Database } from './sqlite'; +import { applyPragmas, ensureSchema } from './storage'; + +test('vocabulary summary worker reads the database from a separate connection', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-vocabulary-summary-worker-')); + const dbPath = path.join(tempDir, 'immersion.sqlite'); + const runtime = new VocabularySummaryWorkerRuntime(); + const db = new Database(dbPath); + + try { + applyPragmas(db); + ensureSchema(db); + db.prepare( + ` + INSERT INTO imm_words ( + headword, word, reading, part_of_speech, pos1, pos2, pos3, + first_seen, last_seen, frequency + ) VALUES ('猫', '猫', 'ねこ', 'noun', '名詞', '一般', '', 1, 1, 1) + `, + ).run(); + db.close(); + + const summary = await runtime.run(dbPath, new Set(['猫'])); + + assert.equal(summary.uniqueWords, 1); + assert.equal(summary.knownWordCount, 1); + } finally { + runtime.destroy(); + try { + db.close(); + } catch { + // The worker needs the setup connection closed before it starts. + } + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('vocabulary summary worker module resolves in the current layout', () => { + const workerPath = resolveVocabularySummaryWorkerPath(); + assert.ok(workerPath, 'expected the vocabulary summary worker module to resolve'); + assert.ok(workerPath.endsWith(__filename.endsWith('.ts') ? '.ts' : '.js')); +}); + +test('vocabulary summary worker never falls back to the caller thread', async () => { + const runtime = new VocabularySummaryWorkerRuntime({ + resolveWorkerPath: () => null, + warn: () => {}, + }); + + try { + await assert.rejects( + runtime.run('/tmp/subminer-summary-worker-not-used.sqlite', null), + /worker unavailable/i, + ); + } finally { + runtime.destroy(); + } +}); + +test('vocabulary summary worker times out when it never responds', async () => { + let terminated = false; + const runtime = new VocabularySummaryWorkerRuntime({ + resolveWorkerPath: () => '/tmp/fake-worker.js', + createWorker: async () => ({ + once() { + return this; + }, + terminate: async () => { + terminated = true; + return 0; + }, + }), + timeoutMs: 1, + warn: () => {}, + } as never); + + try { + const outcome = await Promise.race([ + runtime.run('/tmp/not-used.sqlite', null).then( + () => 'resolved', + (error: unknown) => String(error), + ), + new Promise((resolve) => setTimeout(() => resolve('still pending'), 50)), + ]); + + assert.match(outcome, /timed out/); + assert.equal(terminated, true); + } finally { + runtime.destroy(); + } +}); diff --git a/src/core/services/immersion-tracker/vocabulary-summary-worker-runtime.ts b/src/core/services/immersion-tracker/vocabulary-summary-worker-runtime.ts new file mode 100644 index 00000000..3c0d7e56 --- /dev/null +++ b/src/core/services/immersion-tracker/vocabulary-summary-worker-runtime.ts @@ -0,0 +1,133 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createLogger } from '../../../logger'; +import type { VocabularyStatsSummary } from './types'; + +interface VocabularySummaryWorkerResponse { + summary?: VocabularyStatsSummary; + error?: unknown; +} + +interface VocabularySummaryWorkerHandle { + once(event: 'message', listener: (message: VocabularySummaryWorkerResponse) => void): this; + once(event: 'error', listener: (error: Error) => void): this; + once(event: 'exit', listener: (code: number) => void): this; + terminate(): Promise; +} + +interface VocabularySummaryWorkerRuntimeOptions { + resolveWorkerPath?: () => string | null; + createWorker?: ( + workerPath: string, + workerData: { dbPath: string; knownWords: string[] | null }, + ) => Promise; + timeoutMs?: number; + warn?: (message: string, ...meta: unknown[]) => void; +} + +export type RunVocabularySummaryTask = ( + dbPath: string, + knownWords: ReadonlySet | null, +) => Promise; + +export function resolveVocabularySummaryWorkerPath(): string | null { + const fileName = __filename.endsWith('.ts') + ? 'vocabulary-summary-worker-thread.ts' + : 'vocabulary-summary-worker-thread.js'; + const workerPath = path.join(__dirname, fileName); + return fs.existsSync(workerPath) ? workerPath : null; +} + +const logger = createLogger('main:immersion-tracker:vocabulary-summary-worker'); +const DEFAULT_WORKER_TIMEOUT_MS = 5 * 60 * 1_000; + +export class VocabularySummaryWorkerRuntime { + private readonly activeWorkers = new Set(); + private destroyed = false; + + constructor(private readonly options: VocabularySummaryWorkerRuntimeOptions = {}) {} + + async run( + dbPath: string, + knownWords: ReadonlySet | null, + ): Promise { + if (this.destroyed) throw new Error('Vocabulary summary worker is shut down'); + const workerData = { dbPath, knownWords: knownWords ? [...knownWords] : null }; + let worker: VocabularySummaryWorkerHandle; + try { + const workerPath = (this.options.resolveWorkerPath ?? resolveVocabularySummaryWorkerPath)(); + if (!workerPath) throw new Error('Emitted vocabulary summary worker module was not found'); + const createWorker = + this.options.createWorker ?? + (async (resolvedPath, data) => { + const { Worker } = await import('node:worker_threads'); + return new Worker(resolvedPath, { workerData: data }); + }); + worker = await createWorker(workerPath, workerData); + } catch (error) { + if (this.destroyed) throw new Error('Vocabulary summary worker is shut down'); + (this.options.warn ?? logger.warn)( + 'Vocabulary summary worker unavailable; refusing to scan vocabulary on the current thread', + error, + ); + throw new Error('Vocabulary summary worker unavailable'); + } + + if (this.destroyed) { + await worker.terminate().catch(() => undefined); + throw new Error('Vocabulary summary worker is shut down'); + } + + return new Promise((resolve, reject) => { + let settled = false; + let timeout: ReturnType | null = null; + this.activeWorkers.add(worker); + const settle = (result: VocabularyStatsSummary | Error) => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + this.activeWorkers.delete(worker); + void worker.terminate().catch(() => undefined); + if (result instanceof Error) reject(result); + else resolve(result); + }; + timeout = setTimeout( + () => settle(new Error('Vocabulary summary worker timed out')), + this.options.timeoutMs ?? DEFAULT_WORKER_TIMEOUT_MS, + ); + + worker.once('message', (message) => { + if (message.summary) { + settle(message.summary); + return; + } + settle( + new Error( + `Vocabulary summary failed: ${String(message.error ?? 'unknown worker error')}`, + ), + ); + }); + worker.once('error', (error) => settle(error)); + worker.once('exit', (code) => { + if (!settled) { + settle( + new Error( + code === 0 + ? 'Vocabulary summary worker exited without a response' + : `Vocabulary summary worker exited with code ${code}`, + ), + ); + } + }); + }); + } + + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + for (const worker of this.activeWorkers) { + void worker.terminate().catch(() => undefined); + } + this.activeWorkers.clear(); + } +} diff --git a/src/core/services/immersion-tracker/vocabulary-summary-worker-thread.ts b/src/core/services/immersion-tracker/vocabulary-summary-worker-thread.ts new file mode 100644 index 00000000..abd10481 --- /dev/null +++ b/src/core/services/immersion-tracker/vocabulary-summary-worker-thread.ts @@ -0,0 +1,19 @@ +import { parentPort, workerData } from 'node:worker_threads'; +import { executeVocabularySummaryTask } from './vocabulary-summary-worker'; + +interface VocabularySummaryWorkerData { + dbPath: string; + knownWords: string[] | null; +} + +if (!parentPort) throw new Error('vocabulary summary worker missing parent port'); + +const request = workerData as VocabularySummaryWorkerData; + +try { + parentPort.postMessage({ + summary: executeVocabularySummaryTask(request.dbPath, request.knownWords), + }); +} catch (error) { + parentPort.postMessage({ error: error instanceof Error ? error.message : String(error) }); +} diff --git a/src/core/services/immersion-tracker/vocabulary-summary-worker.ts b/src/core/services/immersion-tracker/vocabulary-summary-worker.ts new file mode 100644 index 00000000..55e34c9f --- /dev/null +++ b/src/core/services/immersion-tracker/vocabulary-summary-worker.ts @@ -0,0 +1,17 @@ +import { getVocabularySummary } from './query-lexical'; +import { Database } from './sqlite'; +import { applyPragmas } from './storage'; +import type { VocabularyStatsSummary } from './types'; + +export function executeVocabularySummaryTask( + dbPath: string, + knownWords: string[] | null, +): VocabularyStatsSummary { + const db = new Database(dbPath); + try { + applyPragmas(db); + return getVocabularySummary(db, knownWords ? new Set(knownWords) : null); + } finally { + db.close(); + } +} diff --git a/src/core/services/immersion-tracker/vocabulary-visibility.ts b/src/core/services/immersion-tracker/vocabulary-visibility.ts new file mode 100644 index 00000000..ba939512 --- /dev/null +++ b/src/core/services/immersion-tracker/vocabulary-visibility.ts @@ -0,0 +1,43 @@ +import { PartOfSpeech, type MergedToken } from '../../../types'; +import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage'; + +export interface VocabularyVisibilityRow { + word: string | null; + headword: string | null; + reading?: string | null; + partOfSpeech?: string | null; + pos1?: string | null; + pos2?: string | null; + pos3?: string | null; + frequencyRank?: number | null; +} + +function toVocabularyToken(row: VocabularyVisibilityRow): MergedToken { + const word = row.word ?? ''; + const headword = row.headword ?? word; + const partOfSpeech = + row.partOfSpeech && Object.values(PartOfSpeech).includes(row.partOfSpeech as PartOfSpeech) + ? (row.partOfSpeech as PartOfSpeech) + : PartOfSpeech.other; + + return { + surface: word, + reading: row.reading ?? '', + headword, + startPos: 0, + endPos: word.length, + partOfSpeech, + pos1: row.pos1 ?? '', + pos2: row.pos2 ?? '', + pos3: row.pos3 ?? '', + frequencyRank: row.frequencyRank ?? undefined, + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }; +} + +export function isVocabularyStatsRowVisible(row: VocabularyVisibilityRow): boolean { + if (!(row.word?.trim() || row.headword?.trim())) return false; + return !shouldExcludeTokenFromVocabularyPersistence(toVocabularyToken(row)); +} diff --git a/src/core/services/index.ts b/src/core/services/index.ts index ecaeb03a..503493ba 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -50,6 +50,11 @@ export { } from './tokenizer/yomitan-parser-runtime'; export { syncYomitanDefaultAnkiServer } from './tokenizer/yomitan-parser-runtime'; export { createSubtitleProcessingController } from './subtitle-processing-controller'; +export { + resolveSanitizedSubtitleSeekCommand, + subtitleCueListSeekTime, + subtitleCueSeekTime, +} from './subtitle-cue-navigation'; export { createFrequencyDictionaryLookup } from './frequency-dictionary'; export { createJlptVocabularyLookup } from './jlpt-vocab'; export { @@ -126,12 +131,6 @@ export { resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime, ticksToSeconds as jellyfinTicksToSecondsRuntime, } from './jellyfin'; -export { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay'; -export { - estimateSubtitleTimingOffset, - type SubtitleTimingOffsetOptions, - type SubtitleTimingOffsetResult, -} from './subtitle-timing-offset'; export { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote'; export { broadcastRuntimeOptionsChangedRuntime, diff --git a/src/core/services/jellyfin-subtitle-delay.test.ts b/src/core/services/jellyfin-subtitle-delay.test.ts deleted file mode 100644 index 6f844d49..00000000 --- a/src/core/services/jellyfin-subtitle-delay.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import assert from 'node:assert/strict'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import test from 'node:test'; -import { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay'; - -function statePath(name: string): string { - return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-delay-')), name); -} - -test('jellyfin subtitle delay store saves and loads delay by item and stream', () => { - const filePath = statePath('delays.json'); - - assert.equal( - saveJellyfinSubtitleDelay({ - filePath, - itemId: 'episode-1', - streamIndex: 3, - delaySeconds: 1.25, - }), - true, - ); - - assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 1.25); - assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), null); -}); - -test('jellyfin subtitle delay store preserves other stream delays when updating one stream', () => { - const filePath = statePath('delays.json'); - - saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 1.25 }); - saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4, delaySeconds: -0.5 }); - saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 2 }); - - assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 2); - assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), -0.5); -}); - -test('jellyfin subtitle delay store ignores invalid files and values', () => { - const filePath = statePath('delays.json'); - fs.writeFileSync(filePath, '{'); - - assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), null); - assert.equal( - saveJellyfinSubtitleDelay({ - filePath, - itemId: 'episode-1', - streamIndex: 3, - delaySeconds: Number.NaN, - }), - false, - ); -}); diff --git a/src/core/services/jellyfin-subtitle-delay.ts b/src/core/services/jellyfin-subtitle-delay.ts deleted file mode 100644 index 18bf8b3b..00000000 --- a/src/core/services/jellyfin-subtitle-delay.ts +++ /dev/null @@ -1,66 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -type JellyfinSubtitleDelayStore = { - version?: unknown; - delays?: unknown; -}; - -type JellyfinSubtitleDelayParams = { - filePath: string; - itemId: string; - streamIndex: number; -}; - -type SaveJellyfinSubtitleDelayParams = JellyfinSubtitleDelayParams & { - delaySeconds: number; -}; - -function storeKey(itemId: string, streamIndex: number): string { - return JSON.stringify([itemId, streamIndex]); -} - -function readDelayMap(filePath: string): Record { - try { - if (!fs.existsSync(filePath)) return {}; - const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as JellyfinSubtitleDelayStore; - if ( - !parsed || - typeof parsed !== 'object' || - !parsed.delays || - typeof parsed.delays !== 'object' - ) { - return {}; - } - const delays: Record = {}; - for (const [key, value] of Object.entries(parsed.delays as Record)) { - if (typeof value === 'number' && Number.isFinite(value)) { - delays[key] = value; - } - } - return delays; - } catch { - return {}; - } -} - -export function loadJellyfinSubtitleDelay(params: JellyfinSubtitleDelayParams): number | null { - const delay = readDelayMap(params.filePath)[storeKey(params.itemId, params.streamIndex)]; - return typeof delay === 'number' && Number.isFinite(delay) ? delay : null; -} - -export function saveJellyfinSubtitleDelay(params: SaveJellyfinSubtitleDelayParams): boolean { - if (!Number.isFinite(params.delaySeconds)) return false; - try { - const delays = readDelayMap(params.filePath); - delays[storeKey(params.itemId, params.streamIndex)] = params.delaySeconds; - const dir = path.dirname(params.filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(params.filePath, JSON.stringify({ version: 1, delays }, null, 2)); - return true; - } catch { - return false; - } -} diff --git a/src/core/services/mining.test.ts b/src/core/services/mining.test.ts index 7431ed6b..a39bca09 100644 --- a/src/core/services/mining.test.ts +++ b/src/core/services/mining.test.ts @@ -125,9 +125,83 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async () ]); }); -test('mineSentenceCard refreshes secondary subtitle text before creating card', async () => { +test('mineSentenceCard prefers a canonical primary subtitle snapshot', async () => { + const created: Array<{ + sentence: string; + startTime: number; + endTime: number; + secondarySub?: string; + }> = []; + + await mineSentenceCard({ + ankiIntegration: { + updateLastAddedFromClipboard: async () => {}, + triggerFieldGroupingForLastAddedCard: async () => {}, + markLastCardAsAudioCard: async () => {}, + createSentenceCard: async (sentence, startTime, endTime, secondarySub) => { + created.push({ sentence, startTime, endTime, secondarySub }); + return true; + }, + }, + mpvClient: { + connected: true, + currentSubText: '今今今手手手', + currentSubStart: 11.4, + currentSubEnd: 11.8, + currentSecondarySubText: 'English subtitle', + }, + primarySubtitle: { + text: '今 手にある物差しでは', + startTime: 11.13, + endTime: 13.83, + }, + showMpvOsd: () => {}, + }); + + assert.deepEqual(created, [ + { + sentence: '今 手にある物差しでは', + startTime: 11.13, + endTime: 13.83, + secondarySub: 'English subtitle', + }, + ]); +}); + +test('mineSentenceCard uses normalized secondary subtitle state instead of raw mpv text', async () => { + const created: Array<{ sentence: string; secondarySub?: string }> = []; + let requestedRawSecondaryText = false; + + await mineSentenceCard({ + ankiIntegration: { + updateLastAddedFromClipboard: async () => {}, + triggerFieldGroupingForLastAddedCard: async () => {}, + markLastCardAsAudioCard: async () => {}, + createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => { + created.push({ sentence, secondarySub }); + return true; + }, + }, + mpvClient: { + connected: true, + currentSubText: '日本語字幕', + currentSubStart: 10, + currentSubEnd: 12, + currentSecondarySubText: 'Your\nmosaic', + requestProperty: async () => { + requestedRawSecondaryText = true; + return 'Your\nYour\nYour\nYour\nmosaic'; + }, + }, + showMpvOsd: () => {}, + }); + + assert.equal(requestedRawSecondaryText, false); + assert.deepEqual(created, [{ sentence: '日本語字幕', secondarySub: 'Your\nmosaic' }]); +}); + +test('mineSentenceCard omits normalized secondary text that matches the primary subtitle', async () => { const created: Array<{ sentence: string; secondarySub?: string }> = []; - const requestedProperties: string[] = []; await mineSentenceCard({ ankiIntegration: { @@ -145,43 +219,6 @@ test('mineSentenceCard refreshes secondary subtitle text before creating card', currentSubStart: 10, currentSubEnd: 12, currentSecondarySubText: '日本語字幕', - requestProperty: async (name: string) => { - requestedProperties.push(name); - return name === 'secondary-sub-text' ? 'English subtitle' : null; - }, - }, - showMpvOsd: () => {}, - }); - - assert.deepEqual(requestedProperties, ['secondary-sub-text']); - assert.deepEqual(created, [{ sentence: '日本語字幕', secondarySub: 'English subtitle' }]); -}); - -test('mineSentenceCard does not fall back to stale cached secondary subtitle after successful refresh', async () => { - const created: Array<{ sentence: string; secondarySub?: string }> = []; - - await mineSentenceCard({ - ankiIntegration: { - updateLastAddedFromClipboard: async () => {}, - triggerFieldGroupingForLastAddedCard: async () => {}, - markLastCardAsAudioCard: async () => {}, - createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => { - created.push({ sentence, secondarySub }); - return true; - }, - }, - mpvClient: { - connected: true, - currentSubText: '日本語字幕', - currentSubStart: 10, - currentSubEnd: 12, - currentSecondarySubText: 'stale cached subtitle', - requestProperty: async (name: string) => { - if (name === 'secondary-sub-text') { - return ''; - } - return null; - }, }, showMpvOsd: () => {}, }); diff --git a/src/core/services/mining.ts b/src/core/services/mining.ts index 9dac45de..62b199e5 100644 --- a/src/core/services/mining.ts +++ b/src/core/services/mining.ts @@ -129,18 +129,10 @@ function normalizeSecondarySubText(text: unknown, primaryText: string): string | return trimmed; } -async function getCurrentSecondarySubTextForSentenceCard( +function getCurrentSecondarySubTextForSentenceCard( mpvClient: MpvClientLike, -): Promise { - const primaryText = mpvClient.currentSubText; - if (mpvClient.requestProperty) { - try { - const latestSecondaryText = await mpvClient.requestProperty('secondary-sub-text'); - return normalizeSecondarySubText(latestSecondaryText, primaryText); - } catch { - // Fall back to the cached secondary subtitle below. - } - } + primaryText: string, +): string | undefined { return normalizeSecondarySubText(mpvClient.currentSecondarySubText, primaryText); } @@ -175,6 +167,7 @@ export async function markLastCardAsAudioCard(deps: { export async function mineSentenceCard(deps: { ankiIntegration: AnkiIntegrationLike | null; mpvClient: MpvClientLike | null; + primarySubtitle?: Pick; showMpvOsd: (text: string) => void; }): Promise { const anki = requireAnkiIntegration(deps.ankiIntegration, deps.showMpvOsd); @@ -185,16 +178,17 @@ export async function mineSentenceCard(deps: { deps.showMpvOsd('MPV not connected'); return false; } - if (!mpvClient.currentSubText) { + const primaryText = deps.primarySubtitle?.text ?? mpvClient.currentSubText; + if (!primaryText) { deps.showMpvOsd('No current subtitle'); return false; } - const secondarySubText = await getCurrentSecondarySubTextForSentenceCard(mpvClient); + const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText); return await anki.createSentenceCard( - mpvClient.currentSubText, - mpvClient.currentSubStart, - mpvClient.currentSubEnd, + primaryText, + deps.primarySubtitle?.startTime ?? mpvClient.currentSubStart, + deps.primarySubtitle?.endTime ?? mpvClient.currentSubEnd, secondarySubText, ); } diff --git a/src/core/services/mpv-properties.ts b/src/core/services/mpv-properties.ts index c20db48e..ae7f2b78 100644 --- a/src/core/services/mpv-properties.ts +++ b/src/core/services/mpv-properties.ts @@ -65,6 +65,8 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [ 'secondary-sub-visibility', 'sub-visibility', 'sid', + 'secondary-sid', + 'secondary-sub-delay', 'track-list', ]; diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 11991db3..b92d23ac 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -63,6 +63,8 @@ function createDeps(overrides: Partial = {}): { emitSubtitleTiming: (payload) => state.events.push(payload), emitSecondarySubtitleChange: (payload) => state.events.push(payload), emitSubtitleTrackChange: (payload) => state.events.push(payload), + emitSecondarySubtitleTrackChange: (payload) => state.events.push(payload), + emitSecondarySubtitleDelayChange: (payload) => state.events.push(payload), emitSubtitleTrackListChange: (payload) => state.events.push(payload), getCurrentSubText: () => state.subText, setCurrentSubText: (text) => { @@ -158,12 +160,42 @@ test('dispatchMpvProtocolMessage emits subtitle track changes', async () => { }); await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: '3' }, deps); + await dispatchMpvProtocolMessage( + { event: 'property-change', name: 'secondary-sid', data: '4' }, + deps, + ); + await dispatchMpvProtocolMessage( + { event: 'property-change', name: 'secondary-sub-delay', data: '0.5' }, + deps, + ); await dispatchMpvProtocolMessage( { event: 'property-change', name: 'track-list', data: [{ type: 'sub', id: 3 }] }, deps, ); - assert.deepEqual(state.events, [{ sid: 3 }, { trackList: [{ type: 'sub', id: 3 }] }]); + assert.deepEqual(state.events, [ + { sid: 3 }, + { sid: 4 }, + { delay: 0.5 }, + { trackList: [{ type: 'sub', id: 3 }] }, + ]); +}); + +test('dispatchMpvProtocolMessage rejects decimal subtitle track IDs', async () => { + const { deps, state } = createDeps(); + + await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: '4.5' }, deps); + await dispatchMpvProtocolMessage( + { event: 'property-change', name: 'secondary-sid', data: '4.5' }, + deps, + ); + await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: 4.5 }, deps); + await dispatchMpvProtocolMessage( + { event: 'property-change', name: 'secondary-sid', data: 4.5 }, + deps, + ); + + assert.deepEqual(state.events, [{ sid: null }, { sid: null }, { sid: null }, { sid: null }]); }); test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => { diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index c8c8a22f..c892a577 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -54,6 +54,8 @@ export interface MpvProtocolHandleMessageDeps { emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void; emitSecondarySubtitleChange: (payload: { text: string }) => void; emitSubtitleTrackChange: (payload: { sid: number | null }) => void; + emitSecondarySubtitleTrackChange: (payload: { sid: number | null }) => void; + emitSecondarySubtitleDelayChange: (payload: { delay: number }) => void; emitSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void; getCurrentSubText: () => string; setCurrentSubText: (text: string) => void; @@ -281,7 +283,25 @@ export async function dispatchMpvProtocolMessage( : typeof msg.data === 'string' ? Number(msg.data) : null; - deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isFinite(sid) ? sid : null }); + deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isInteger(sid) ? sid : null }); + } else if (msg.name === 'secondary-sid') { + const sid = + typeof msg.data === 'number' + ? msg.data + : typeof msg.data === 'string' + ? Number(msg.data) + : null; + deps.emitSecondarySubtitleTrackChange({ + sid: sid !== null && Number.isInteger(sid) ? sid : null, + }); + } else if (msg.name === 'secondary-sub-delay') { + const delay = + typeof msg.data === 'number' + ? msg.data + : typeof msg.data === 'string' + ? Number(msg.data) + : 0; + deps.emitSecondarySubtitleDelayChange({ delay: Number.isFinite(delay) ? delay : 0 }); } else if (msg.name === 'track-list') { deps.emitSubtitleTrackListChange({ trackList: Array.isArray(msg.data) ? (msg.data as unknown[]) : null, diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index a4e8c96c..271e9735 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -131,6 +131,8 @@ export interface MpvIpcClientEventMap { 'fullscreen-change': { fullscreen: boolean }; 'secondary-subtitle-change': { text: string }; 'subtitle-track-change': { sid: number | null }; + 'secondary-subtitle-track-change': { sid: number | null }; + 'secondary-subtitle-delay-change': { delay: number }; 'subtitle-track-list-change': { trackList: unknown[] | null }; 'media-path-change': { path: string }; 'media-title-change': { title: string | null }; @@ -438,6 +440,12 @@ export class MpvIpcClient implements MpvClient { emitSubtitleTrackChange: (payload) => { this.emit('subtitle-track-change', payload); }, + emitSecondarySubtitleTrackChange: (payload) => { + this.emit('secondary-subtitle-track-change', payload); + }, + emitSecondarySubtitleDelayChange: (payload) => { + this.emit('secondary-subtitle-delay-change', payload); + }, emitSubtitleTrackListChange: (payload) => { this.emit('subtitle-track-list-change', payload); }, diff --git a/src/core/services/overlay-runtime-init.ts b/src/core/services/overlay-runtime-init.ts index 6f452336..1042c6fc 100644 --- a/src/core/services/overlay-runtime-init.ts +++ b/src/core/services/overlay-runtime-init.ts @@ -21,6 +21,7 @@ type CreateAnkiIntegrationArgs = { mpvClient: { send?: (payload: { command: string[] }) => void }; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -74,6 +75,7 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte args.getCachedMediaPath, args.shouldRequireRemoteMediaCache, args.getYoutubeMediaSourceUrl, + args.dismissOverlayNotification, ); } @@ -137,6 +139,7 @@ export function initializeOverlayRuntime( setAnkiIntegration: (integration: unknown | null) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -177,6 +180,7 @@ export function initializeOverlayAnkiIntegration(options: { setAnkiIntegration: (integration: unknown | null) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -219,6 +223,7 @@ export function initializeOverlayAnkiIntegration(options: { mpvClient, showDesktopNotification: options.showDesktopNotification, showOverlayNotification: options.showOverlayNotification, + dismissOverlayNotification: options.dismissOverlayNotification, createFieldGroupingCallback: options.createFieldGroupingCallback, knownWordCacheStatePath: options.getKnownWordCacheStatePath(), ...(options.getCachedMediaPath ? { getCachedMediaPath: options.getCachedMediaPath } : {}), diff --git a/src/core/services/secondary-subtitle-line-identity.ts b/src/core/services/secondary-subtitle-line-identity.ts new file mode 100644 index 00000000..16442abf --- /dev/null +++ b/src/core/services/secondary-subtitle-line-identity.ts @@ -0,0 +1,15 @@ +const MIN_FLATTENED_DUPLICATE_LENGTH = 16; +const TERMINAL_SENTENCE_PUNCTUATION = /[.!?。!?…⋯]+$/gu; + +/** + * Identifies long lines that become duplicates when positioned ASS events are + * flattened into the secondary subtitle bar. Short dialogue stays distinct. + */ +export function flattenedSecondarySubtitleLineIdentity(text: string): string | null { + const identity = text + .normalize('NFKC') + .replace(/\s+/gu, '') + .replace(TERMINAL_SENTENCE_PUNCTUATION, ''); + + return identity.length >= MIN_FLATTENED_DUPLICATE_LENGTH ? identity : null; +} diff --git a/src/core/services/stats-server/library-routes.ts b/src/core/services/stats-server/library-routes.ts index 89866d9b..f705f1ba 100644 --- a/src/core/services/stats-server/library-routes.ts +++ b/src/core/services/stats-server/library-routes.ts @@ -10,6 +10,7 @@ import { parseExcludedWordsBody, parseIntQuery, parsePositiveIdList, + loadKnownWordsSet, } from './route-support.js'; export function registerStatsLibraryRoutes( @@ -31,6 +32,17 @@ export function registerStatsLibraryRoutes( return c.json(statsJson('vocabulary', vocab)); }); + app.get('/api/stats/vocabulary/summary', async (c) => { + const summary = await tracker.getVocabularySummary( + loadKnownWordsSet(options?.knownWordCachePath), + ); + return c.json(statsJson('vocabularySummary', summary)); + }); + + app.get('/api/stats/vocabulary/charts', async (c) => { + return c.json(statsJson('vocabularyCharts', await tracker.getVocabularyChartData())); + }); + app.get('/api/stats/excluded-words', async (c) => { return c.json(statsJson('excludedWords', await tracker.getStatsExcludedWords())); }); diff --git a/src/core/services/stats-sync/merge-catalog.ts b/src/core/services/stats-sync/merge-catalog.ts index 9b8c9b54..a58b8d62 100644 --- a/src/core/services/stats-sync/merge-catalog.ts +++ b/src/core/services/stats-sync/merge-catalog.ts @@ -89,6 +89,7 @@ const WORD_COPY_COLUMNS = [ 'last_seen', 'frequency', 'frequency_rank', + 'vocabulary_visible', ] as const; export function mergeAnime( diff --git a/src/core/services/subtitle-cue-dedup.ts b/src/core/services/subtitle-cue-dedup.ts index 8d7a7d88..6a0b6a25 100644 --- a/src/core/services/subtitle-cue-dedup.ts +++ b/src/core/services/subtitle-cue-dedup.ts @@ -27,17 +27,188 @@ function cueKey(cue: SubtitleCue): string { /** * Identical text over an identical span is redundant however it was authored -- most - * often a layered ASS event stacking a shadow copy under the visible one. + * often a layered ASS event stacking a shadow copy under the visible one. When one of + * the duplicates is a recovered canonical cue, that copy survives: dropping it would + * strip the `source` marker and animation envelope the live overlay substitutes on. */ function collapseExactDuplicates(cues: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] { - const seen = new Set(); - return cues.filter((cue) => { + const survivorByKey = new Map(); + const keysInOrder: string[] = []; + for (const cue of cues) { const key = cueKey(cue); - if (seen.has(key)) { - return false; + const existing = survivorByKey.get(key); + if (!existing) { + survivorByKey.set(key, cue); + keysInOrder.push(key); + } else if (!existing.source && cue.source) { + survivorByKey.set(key, cue); } - seen.add(key); + } + return keysInOrder.map((key) => survivorByKey.get(key)!); +} + +const SPATIAL_ASS_OVERRIDE_COMMANDS = new Set([ + 'a', + 'an', + 'clip', + 'iclip', + 'move', + 'org', + 'pbo', + 'pos', + 'q', +]); + +interface RepeatedPhaseRun { + cues: AnnotatedSubtitleCue[]; + indices: number[]; +} + +// A changing override signature alone is weak: two ordinary repeats restyled with +// different colors look identical to a phase pair. Real phase redraws carry a styling +// stack over a full lyric line, and they exist to move a color/highlight boundary +// *within* the line -- so every event also has an override block after visible text +// began. An ordinary restyled repeat carries only a leading block and stays separate. +const MIN_PHASE_EVIDENCE_OVERRIDES = 2; +const MIN_PHASE_TEXT_LENGTH = 4; + +function hasMidLineOverrideBlock(rawText: string): boolean { + let sawVisibleText = false; + for (let i = 0; i < rawText.length; i += 1) { + if (rawText[i] === '{') { + const close = rawText.indexOf('}', i); + if (close === -1) { + // Unclosed brace renders as literal text; nothing after it is markup. + return false; + } + if (sawVisibleText) { + return true; + } + i = close; + } else if (!/\s/.test(rawText[i]!)) { + sawVisibleText = true; + } + } + return false; +} + +function assStyleKey(cue: AnnotatedSubtitleCue): string { + return `${cue.style}\0${cue.name}\0${cue.layer}`; +} + +function spatialOverrideSignature(cue: AnnotatedSubtitleCue): string { + return cue.overrides + .filter((command) => SPATIAL_ASS_OVERRIDE_COMMANDS.has(command.name.toLowerCase())) + .map((command) => `${command.name.toLowerCase()}(${command.args})`) + .join('|'); +} + +function hasStableSpatialOverrides(run: readonly AnnotatedSubtitleCue[]): boolean { + const firstSignature = spatialOverrideSignature(run[0]!); + return run.every((cue) => spatialOverrideSignature(cue) === firstSignature); +} + +function hasDirectPhaseEvidence(run: readonly AnnotatedSubtitleCue[]): boolean { + // Phases redraw one authored line in place. Whatever the animation evidence, a run + // whose spatial placement changes is separate authored occurrences -- two flush + // same-text `\move` signs at different coordinates must never merge. + if (!hasStableSpatialOverrides(run)) { + return false; + } + if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) { return true; + } + if (run.every((cue) => isAnimatedAssEffectKind(cue.effectKind))) { + return true; + } + + const [first] = run; + return ( + first!.text.replace(/\s+/gu, '').length >= MIN_PHASE_TEXT_LENGTH && + run.every( + (cue) => + cue.overrides.length >= MIN_PHASE_EVIDENCE_OVERRIDES && + hasMidLineOverrideBlock(cue.rawText), + ) && + run.some((cue) => cue.overrideSignature !== first!.overrideSignature) + ); +} + +function collectRepeatedPhaseRuns(cues: AnnotatedSubtitleCue[]): RepeatedPhaseRun[] { + const runs: RepeatedPhaseRun[] = []; + let start = 0; + + while (start < cues.length) { + const first = cues[start]!; + const styleKey = assStyleKey(first); + let end = start; + + while (end + 1 < cues.length) { + const current = cues[end]!; + const next = cues[end + 1]!; + const isFlush = + Math.abs(next.startTime - current.endTime) <= DUPLICATE_CUE_GAP_TOLERANCE_SECONDS; + if ( + first.source !== undefined || + next.source !== undefined || + next.text !== first.text || + assStyleKey(next) !== styleKey || + !isFlush + ) { + break; + } + end += 1; + } + + if (end > start) { + const indices = Array.from({ length: end - start + 1 }, (_, offset) => start + offset); + runs.push({ + cues: indices.map((index) => cues[index]!), + indices, + }); + } + start = end + 1; + } + + return runs; +} + +/** + * Some karaoke scripts redraw one complete lyric for each color/highlight phase. These + * events last far longer than animation frames, but are still one sidebar/history line. + * The events must prove themselves through direct animation metadata or changing + * non-spatial overrides. Plain repeated dialogue and separately positioned signs stay + * intact. + */ +function collapseAnimatedStylePhases(cues: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] { + const runs = collectRepeatedPhaseRuns(cues); + if (runs.length === 0) { + return cues; + } + + const dropped = new Set(); + const extendedEnd = new Map(); + for (const run of runs) { + if (!hasDirectPhaseEvidence(run.cues)) { + continue; + } + + const [firstIndex, ...remainingIndices] = run.indices; + for (const index of remainingIndices) { + dropped.add(index); + } + extendedEnd.set(firstIndex!, Math.max(...run.cues.map((cue) => cue.endTime))); + } + + if (dropped.size === 0) { + return cues; + } + return cues.flatMap((cue, index) => { + if (dropped.has(index)) { + return []; + } + const endTime = extendedEnd.get(index); + return endTime !== undefined ? [{ ...cue, endTime }] : [cue]; }); } @@ -52,7 +223,7 @@ function countFramesShorterThan(run: AnnotatedSubtitleCue[], maxSeconds: number) * anything wrapped in `\t(...)`), an animated `Effect` column, or a value that actually * changes from event to event, which is how per-frame typesetting is authored. */ -export function hasAssAnimationEvidence(run: AnnotatedSubtitleCue[]): boolean { +export function hasAssAnimationEvidence(run: readonly AnnotatedSubtitleCue[]): boolean { if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) { return true; } @@ -176,5 +347,8 @@ export function mergeDuplicateCues( cues: AnnotatedSubtitleCue[], format: SubtitleSourceFormat, ): AnnotatedSubtitleCue[] { - return collapseAnimationBursts(collapseExactDuplicates(cues), format); + const exactDeduplicated = collapseExactDuplicates(cues); + const phaseDeduplicated = + format === 'ass' ? collapseAnimatedStylePhases(exactDeduplicated) : exactDeduplicated; + return collapseAnimationBursts(phaseDeduplicated, format); } diff --git a/src/core/services/subtitle-cue-navigation.test.ts b/src/core/services/subtitle-cue-navigation.test.ts new file mode 100644 index 00000000..00198fb4 --- /dev/null +++ b/src/core/services/subtitle-cue-navigation.test.ts @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + resolveSanitizedSubtitleSeekCommand, + subtitleCueListSeekTime, + subtitleCueSeekTime, +} from './subtitle-cue-navigation'; + +test('next subtitle navigation skips generated ASS events and seeks to the next sanitized cue', () => { + const cues = [ + { + startTime: 10, + endTime: 13, + text: 'first lyric', + source: 'canonical-ass' as const, + animationStartTime: 9.7, + animationEndTime: 13.4, + }, + { + startTime: 13, + endTime: 16, + text: 'second lyric', + source: 'canonical-ass' as const, + animationStartTime: 12.7, + animationEndTime: 16.4, + }, + ]; + + assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), [ + 'seek', + 13.08, + 'absolute+exact', + ]); +}); + +test('next subtitle navigation treats simultaneous sanitized cues as one line boundary', () => { + const cues = [ + { startTime: 10, endTime: 13, text: 'romaji' }, + { startTime: 10.02, endTime: 13, text: 'English' }, + { startTime: 13, endTime: 16, text: 'next romaji' }, + { startTime: 13.02, endTime: 16, text: 'Next English' }, + ]; + + assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1), [ + 'seek', + 13.08, + 'absolute+exact', + ]); +}); + +test('next subtitle navigation advances past the latest overlapping lyric', () => { + const cues = [ + { startTime: 10, endTime: 14, text: 'exiting lyric' }, + { startTime: 13, endTime: 16, text: 'current lyric' }, + { startTime: 16, endTime: 19, text: 'next lyric' }, + ]; + + assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2), [ + 'seek', + 16.08, + 'absolute+exact', + ]); +}); + +test('previous subtitle navigation leaves the current cue and seeks to the prior cue', () => { + const cues = [ + { startTime: 10, endTime: 12, text: 'first line' }, + { startTime: 13, endTime: 16, text: 'current line' }, + ]; + + assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5), [ + 'seek', + 10.08, + 'absolute+exact', + ]); +}); + +test('subtitle navigation falls back when no sanitized destination exists', () => { + const cues = [{ startTime: 10, endTime: 13, text: 'only line' }]; + + assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), null); + assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2), null); +}); + +test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => { + assert.equal(subtitleCueSeekTime({ startTime: 1, endTime: 2, text: 'line' }), 1.08); + assert.equal(subtitleCueSeekTime({ startTime: 1, endTime: 1.04, text: 'short' }), 1.03); +}); + +test('sidebar cue selection clears an overlapping previous lyric', () => { + const cues = [ + { startTime: 1, endTime: 3.4, text: 'previous lyric' }, + { startTime: 3, endTime: 5, text: 'selected lyric' }, + ]; + + assert.equal(subtitleCueListSeekTime(cues, cues[1]!), 3.48); +}); + +test('sidebar cue selection remains inside a short cue when overlap cannot be cleared', () => { + const cues = [ + { startTime: 1, endTime: 3.4, text: 'previous lyric' }, + { startTime: 3, endTime: 3.2, text: 'selected lyric' }, + ]; + + const seekTime = subtitleCueListSeekTime(cues, cues[1]!); + assert.ok(seekTime >= 3.19); + assert.ok(seekTime < cues[1]!.endTime); +}); diff --git a/src/core/services/subtitle-cue-navigation.ts b/src/core/services/subtitle-cue-navigation.ts new file mode 100644 index 00000000..f0248102 --- /dev/null +++ b/src/core/services/subtitle-cue-navigation.ts @@ -0,0 +1,128 @@ +import type { SubtitleCue } from './subtitle-cue-parser'; + +const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05; +const CUE_BOUNDARY_SEEK_OFFSET_SECONDS = 0.08; +const CUE_END_GUARD_SECONDS = 0.01; + +type CueGroup = { + startTime: number; + endTime: number; + cue: SubtitleCue; +}; + +function isValidCue(cue: SubtitleCue): boolean { + return ( + Number.isFinite(cue.startTime) && Number.isFinite(cue.endTime) && cue.endTime > cue.startTime + ); +} + +function groupCueBoundaries(cues: readonly SubtitleCue[]): CueGroup[] { + const sorted = cues.filter(isValidCue).sort((left, right) => { + return left.startTime - right.startTime || left.endTime - right.endTime; + }); + const groups: CueGroup[] = []; + + for (const cue of sorted) { + const current = groups.at(-1); + if (current && cue.startTime - current.startTime <= CUE_START_GROUP_TOLERANCE_SECONDS) { + current.endTime = Math.max(current.endTime, cue.endTime); + continue; + } + groups.push({ startTime: cue.startTime, endTime: cue.endTime, cue }); + } + + return groups; +} + +/** A small offset avoids asking mpv to render exactly on a subtitle boundary. */ +export function subtitleCueSeekTime(cue: SubtitleCue): number { + return Math.max( + cue.startTime, + Math.min(cue.endTime - CUE_END_GUARD_SECONDS, cue.startTime + CUE_BOUNDARY_SEEK_OFFSET_SECONDS), + ); +} + +/** + * Choose a stable point inside a selected cue. Karaoke lines can overlap while the + * previous line animates out, so a sidebar selection should clear that overlap when + * the selected cue has enough time remaining. + */ +export function subtitleCueListSeekTime( + cues: readonly SubtitleCue[], + selectedCue: SubtitleCue, +): number { + const groups = groupCueBoundaries(cues); + const selectedGroupIndex = groups.findIndex( + (group) => + selectedCue.startTime >= group.startTime && + selectedCue.startTime - group.startTime <= CUE_START_GROUP_TOLERANCE_SECONDS, + ); + const previousGroupEndTime = + selectedGroupIndex > 0 ? groups[selectedGroupIndex - 1]?.endTime : undefined; + if (previousGroupEndTime === undefined || previousGroupEndTime <= selectedCue.startTime) { + return subtitleCueSeekTime(selectedCue); + } + + return Math.max( + selectedCue.startTime, + Math.min( + selectedCue.endTime - CUE_END_GUARD_SECONDS, + previousGroupEndTime + CUE_BOUNDARY_SEEK_OFFSET_SECONDS, + ), + ); +} + +/** + * Translate mpv subtitle-line navigation onto parsed cues. Generated ASS karaoke can + * contain hundreds of subtitle events for one visible line, while the parsed list has + * already collapsed those events into the authored lines the user expects to navigate. + */ +export function resolveSanitizedSubtitleSeekCommand( + command: readonly (string | number)[], + cues: readonly SubtitleCue[], + currentTimeSec: number, +): (string | number)[] | null { + if ( + command.length < 2 || + command[0] !== 'sub-seek' || + (command[1] !== -1 && command[1] !== 1) || + !Number.isFinite(currentTimeSec) + ) { + return null; + } + + const groups = groupCueBoundaries(cues); + if (groups.length === 0) { + return null; + } + + let activeIndex = -1; + for (const [index, group] of groups.entries()) { + if (group.startTime <= currentTimeSec && group.endTime > currentTimeSec) { + activeIndex = index; + } + } + + let destination: CueGroup | undefined; + if (command[1] === 1) { + destination = + activeIndex >= 0 + ? groups[activeIndex + 1] + : groups.find((group) => group.startTime > currentTimeSec); + } else if (activeIndex >= 0) { + destination = groups[activeIndex - 1]; + } else { + for (let index = groups.length - 1; index >= 0; index -= 1) { + const group = groups[index]!; + if (group.startTime < currentTimeSec) { + destination = group; + break; + } + } + } + + if (!destination) { + return null; + } + return ['seek', subtitleCueSeekTime(destination.cue), 'absolute+exact']; +} diff --git a/src/core/services/subtitle-cue-parser.test.ts b/src/core/services/subtitle-cue-parser.test.ts index 86738d04..5163ebfb 100644 --- a/src/core/services/subtitle-cue-parser.test.ts +++ b/src/core/services/subtitle-cue-parser.test.ts @@ -35,6 +35,12 @@ test('parseSrtCues handles multi-line subtitle text', () => { assert.equal(cues[0]!.text, 'これは\nテストです'); }); +test('parseSrtCues preserves lines that only resemble malformed ASS controls', () => { + const content = ['1', '00:01:00,000 --> 00:01:05,000', '\\', '{\\fr0', ''].join('\n'); + + assert.equal(parseSrtCues(content)[0]?.text, '\\\n{\\fr0'); +}); + test('parseSrtCues strips HTML-like markup while preserving line breaks', () => { const content = [ '1', @@ -327,6 +333,122 @@ test('parseSubtitleCues collapses per-frame karaoke duplicates into one cue', () assert.equal(cues[0]!.text, '過ぎ去ってしまう瞬間を'); }); +test('parseSubtitleCues collapses long full-line color phases', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 1,0:03:49.75,0:03:51.21,OPJP,,0,0,0,,{\\blur0.6\\c&H312D38&\\4c&HFFFFFF&}ちゃんと目を{\\4c&HD590FF&}合わせてよ', + 'Dialogue: 1,0:03:51.21,0:03:52.25,OPJP,,0,0,0,,{\\blur0.6\\4c&H312D38&\\c&HFFFFFF&}ちゃんと目を{\\4c&HD590FF&}合わせてよ', + ].join('\n'); + + assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [ + { + startTime: 229.75, + endTime: 232.25, + text: 'ちゃんと目を合わせてよ', + }, + ]); +}); + +test('parseSubtitleCues keeps ordinary repeated dialogue separate', () => { + // A single restyle tag on a repeated line is how ordinary dialogue gets decorated; + // it is not phase evidence, whatever the line length. + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 1,0:00:01.00,0:00:02.00,OPJP,,0,0,0,,{\\c&H111111&}歌詞', + 'Dialogue: 1,0:00:02.00,0:00:03.00,OPJP,,0,0,0,,{\\c&H222222&}歌詞', + 'Dialogue: 1,0:00:04.00,0:00:05.00,OPJP,,0,0,0,,{\\c&H333333&}別の歌詞', + 'Dialogue: 1,0:00:05.00,0:00:06.00,OPJP,,0,0,0,,{\\c&H444444&}別の歌詞', + 'Dialogue: 8,0:00:07.00,0:00:08.00,Text - JP,,0,0,0,,えっ?', + 'Dialogue: 8,0:00:08.00,0:00:09.00,Text - JP,,0,0,0,,えっ?', + ].join('\n'); + + assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [ + { startTime: 1, endTime: 2, text: '歌詞' }, + { startTime: 2, endTime: 3, text: '歌詞' }, + { startTime: 4, endTime: 5, text: '別の歌詞' }, + { startTime: 5, endTime: 6, text: '別の歌詞' }, + { startTime: 7, endTime: 8, text: 'えっ?' }, + { startTime: 8, endTime: 9, text: 'えっ?' }, + ]); +}); + +test('parseSubtitleCues keeps separately positioned temporal signs separate', () => { + // Two flush signs with the same text but different \move paths are separate authored + // occurrences, not phases of one redraw: temporal evidence alone must not merge them. + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:02.00,Sign,,0,0,0,,{\\move(100,100,200,100)}立入禁止', + 'Dialogue: 0,0:00:02.00,0:00:03.00,Sign,,0,0,0,,{\\move(500,400,600,400)}立入禁止', + ].join('\n'); + + assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [ + { startTime: 1, endTime: 2, text: '立入禁止' }, + { startTime: 2, endTime: 3, text: '立入禁止' }, + ]); +}); + +test('parseSubtitleCues keeps richly styled ordinary repeats separate', () => { + // Blur plus a changing color is still an ordinary restyle. Phase redraws are + // recognized by the color/highlight boundary moving *within* the line, which these + // leading-block-only events do not have. + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:02.00,Dial,,0,0,0,,{\\blur0.4\\c&H111111&}待ってよ', + 'Dialogue: 0,0:00:02.00,0:00:03.00,Dial,,0,0,0,,{\\blur0.4\\c&H222222&}待ってよ', + ].join('\n'); + + assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [ + { startTime: 1, endTime: 2, text: '待ってよ' }, + { startTime: 2, endTime: 3, text: '待ってよ' }, + ]); +}); + +test('parseSubtitleCues keeps canonical metadata when an identical plain cue exists', () => { + // A plain dialogue line can share exact timing and text with a recovered canonical + // cue from another style. The canonical copy must win the exact-duplicate collapse, + // or the live overlay loses the marker it substitutes on. + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:05.00,0:00:08.00,Plain,,0,0,0,,ライン', + 'Comment: 0,0:00:05.00,0:00:08.00,OP,,0,0,0,,ライン', + 'Dialogue: 0,0:00:05.00,0:00:05.04,OP,,0,0,0,,{\\pos(1,1)\\clip(m 1 1)}ライン', + 'Dialogue: 0,0:00:05.04,0:00:05.08,OP,,0,0,0,,{\\pos(1,1)\\clip(m 2 2)}ライン', + 'Dialogue: 0,0:00:05.08,0:00:08.00,OP,,0,0,0,,{\\pos(1,1)\\clip(m 3 3)}ライン', + ].join('\n'); + + assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [ + { + startTime: 5, + endTime: 8, + text: 'ライン', + source: 'canonical-ass', + animationStartTime: 5, + animationEndTime: 8, + }, + ]); +}); + +test('parseSubtitleCues keeps short styled repeats separate even with richer styling', () => { + // Two ordinary えっ lines restyled with different colors are two utterances, not two + // phases of one lyric: short text never satisfies the changing-override evidence path. + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:02.00,Dial,,0,0,0,,{\\blur0.4\\c&H111111&}えっ', + 'Dialogue: 0,0:00:02.00,0:00:03.00,Dial,,0,0,0,,{\\blur0.4\\c&H222222&}えっ', + ].join('\n'); + + assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [ + { startTime: 1, endTime: 2, text: 'えっ' }, + { startTime: 2, endTime: 3, text: 'えっ' }, + ]); +}); + test('parseSubtitleCues keeps back-to-back plain dialogue repeats separate', () => { // Several characters greeting in turn: distinct utterances that happen to abut. const content = [ @@ -357,6 +479,274 @@ test('parseSubtitleCues collapses exact duplicate cues even without effect tags' assert.equal(cues.length, 1); }); +test('parseSubtitleCues replaces generated glyph animation with its timed canonical comment', () => { + // Aegisub automation commonly keeps the authored lyric as a Comment and emits + // multiple moving Dialogue layers for every glyph. This mirrors the MyGO ED script: + // three entrance copies followed by three exit copies for each character. + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Comment: 0,0:00:01.20,0:00:03.80,ED_JP,,0,0,0,,{\\fad(480,480)}今 手にある', + 'Dialogue: 0,0:00:00.80,0:00:01.50,ED_JP,,0,0,0,,{\\move(10,20,100,200)\\t(0,600,\\fscx100)}今', + 'Dialogue: 0,0:00:00.80,0:00:01.50,ED_JP,,0,0,0,,{\\move(30,40,100,200)\\t(0,600,\\fscx100)}今', + 'Dialogue: 0,0:00:00.80,0:00:01.50,ED_JP,,0,0,0,,{\\move(50,60,100,200)\\t(0,600,\\fscx100)}今', + 'Dialogue: 1,0:00:01.40,0:00:04.20,ED_JP,,0,0,0,,{\\move(100,200,20,30)\\t(2000,2600,\\blur20)}今', + 'Dialogue: 1,0:00:01.40,0:00:04.20,ED_JP,,0,0,0,,{\\move(100,200,40,50)\\t(2000,2600,\\blur20)}今', + 'Dialogue: 1,0:00:01.40,0:00:04.20,ED_JP,,0,0,0,,{\\move(100,200,60,70)\\t(2000,2600,\\blur20)}今', + 'Dialogue: 0,0:00:00.86,0:00:01.56,ED_JP,,0,0,0,,{\\move(10,20,140,200)\\t(0,600,\\fscx100)}手', + 'Dialogue: 0,0:00:00.86,0:00:01.56,ED_JP,,0,0,0,,{\\move(30,40,140,200)\\t(0,600,\\fscx100)}手', + 'Dialogue: 0,0:00:00.86,0:00:01.56,ED_JP,,0,0,0,,{\\move(50,60,140,200)\\t(0,600,\\fscx100)}手', + 'Dialogue: 1,0:00:01.46,0:00:04.26,ED_JP,,0,0,0,,{\\move(140,200,20,30)\\t(2000,2600,\\blur20)}手', + 'Dialogue: 1,0:00:01.46,0:00:04.26,ED_JP,,0,0,0,,{\\move(140,200,40,50)\\t(2000,2600,\\blur20)}手', + 'Dialogue: 1,0:00:01.46,0:00:04.26,ED_JP,,0,0,0,,{\\move(140,200,60,70)\\t(2000,2600,\\blur20)}手', + 'Dialogue: 0,0:00:00.92,0:00:01.62,ED_JP,,0,0,0,,{\\move(10,20,180,200)\\t(0,600,\\fscx100)}にある', + 'Dialogue: 0,0:00:00.92,0:00:01.62,ED_JP,,0,0,0,,{\\move(30,40,180,200)\\t(0,600,\\fscx100)}にある', + 'Dialogue: 0,0:00:00.92,0:00:01.62,ED_JP,,0,0,0,,{\\move(50,60,180,200)\\t(0,600,\\fscx100)}にある', + 'Dialogue: 1,0:00:01.52,0:00:04.32,ED_JP,,0,0,0,,{\\move(180,200,20,30)\\t(2000,2600,\\blur20)}にある', + 'Dialogue: 1,0:00:01.52,0:00:04.32,ED_JP,,0,0,0,,{\\move(180,200,40,50)\\t(2000,2600,\\blur20)}にある', + 'Dialogue: 1,0:00:01.52,0:00:04.32,ED_JP,,0,0,0,,{\\move(180,200,60,70)\\t(2000,2600,\\blur20)}にある', + 'Dialogue: 0,0:00:06.00,0:00:08.00,Dial_JP,,0,0,0,,普通の会話', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + + assert.deepEqual(cues, [ + { + startTime: 1.2, + endTime: 3.8, + text: '今 手にある', + source: 'canonical-ass', + // Entrance frames start before and exit frames end after the authored timing. + animationStartTime: 0.8, + animationEndTime: 4.32, + }, + { startTime: 6, endTime: 8, text: '普通の会話' }, + ]); +}); + +test('parseSubtitleCues recovers a full Dialogue line surrounding generated fragments', () => { + // Some scripts do not retain the authored line as a Comment. Instead, brief entrance + // and exit events contain the complete line around a long run of generated syllables. + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 1,0:00:01.00,0:00:01.15,ED Romaji,,0,0,0,fx,{\\move(100,40,60,40)}toki yo ugokidase', + 'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(0,300,\\c&HFFFFFF&)}to', + 'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(300,500,\\c&HFFFFFF&)}ki', + 'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(500,700,\\c&HFFFFFF&)}yo', + 'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(700,900,\\c&HFFFFFF&)}u', + 'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(900,1100,\\c&HFFFFFF&)}go', + 'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(1100,1300,\\c&HFFFFFF&)}ki', + 'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(1300,1500,\\c&HFFFFFF&)}da', + 'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(1500,1800,\\c&HFFFFFF&)}se', + 'Dialogue: 1,0:00:03.00,0:00:03.15,ED Romaji,,0,0,0,fx,{\\move(60,40,20,40)}toki yo ugokidase', + 'Dialogue: 0,0:00:06.00,0:00:08.00,Default,,0,0,0,,Ordinary dialogue', + ].join('\n'); + + assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [ + { + startTime: 1, + endTime: 3.15, + text: 'toki yo ugokidase', + source: 'canonical-ass', + animationStartTime: 1, + animationEndTime: 3.15, + }, + { startTime: 6, endTime: 8, text: 'Ordinary dialogue' }, + ]); +}); + +test('parseSubtitleCues replaces animated glyph copies of a static canonical Dialogue line', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 1,0:00:01.00,0:00:04.00,OP - JP,,0,0,0,,重複字幕', + 'Dialogue: 2,0:00:01.00,0:00:04.00,OP - JP,,0,0,0,,{\\pos(400,50)\\t(0,100,\\fry0)}重', + 'Dialogue: 2,0:00:01.10,0:00:04.00,OP - JP,,0,0,0,,{\\pos(440,50)\\t(0,100,\\fry0)}複', + 'Dialogue: 2,0:00:01.20,0:00:04.00,OP - JP,,0,0,0,,{\\pos(480,50)\\t(0,100,\\fry0)}字', + 'Dialogue: 2,0:00:01.30,0:00:04.00,OP - JP,,0,0,0,,{\\pos(520,50)\\t(0,100,\\fry0)}幕', + ].join('\n'); + + assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [ + { + startTime: 1, + endTime: 4, + text: '重複字幕', + source: 'canonical-ass', + animationStartTime: 1, + animationEndTime: 4, + }, + ]); +}); + +test('parseSubtitleCues does not promote a short animated fragment as a complete line', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 1,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}my', + 'Dialogue: 2,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}my', + 'Dialogue: 1,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}m', + 'Dialogue: 2,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}m', + 'Dialogue: 1,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(120,100)\\t(20,120,\\fscx120)}y', + 'Dialogue: 2,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(120,100)\\t(20,120,\\fscx120)}y', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + + assert.equal( + cues.some((cue) => cue.source === 'canonical-ass'), + false, + ); +}); + +test('parseSubtitleCues keeps short animated English dialogue as separate cues', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:02.00,English Dialogue,,0,0,0,,{\\t(0,100,\\fscx110)}Hi', + 'Dialogue: 0,0:00:02.00,0:00:03.00,English Dialogue,,0,0,0,,{\\t(0,100,\\fscx110)}No', + ].join('\n'); + + assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [ + { startTime: 1, endTime: 2, text: 'Hi' }, + { startTime: 2, endTime: 3, text: 'No' }, + ]); +}); + +test('parseSubtitleCues does not reconstruct an already canonical English cue', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Comment: 0,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\move(100,100,120,100)}POOF', + 'Dialogue: 0,0:00:01.00,0:00:01.04,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 1 1)}POOF', + 'Dialogue: 0,0:00:01.04,0:00:01.08,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 2 2)}POOF', + 'Dialogue: 0,0:00:01.08,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 3 3)}POOF', + ].join('\n'); + + assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [ + { + startTime: 1, + endTime: 3, + text: 'POOF', + source: 'canonical-ass', + animationStartTime: 1, + animationEndTime: 3, + }, + ]); +}); + +test('parseSubtitleCues reconstructs a short positioned fragment without a lyric style name', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:03.00,Karaoke,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx110)}Oh', + 'Dialogue: 1,0:00:01.00,0:00:03.00,Karaoke,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx110)}Oh', + ].join('\n'); + + assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [ + { + startTime: 1, + endTime: 3, + text: 'Oh', + source: 'reconstructed-ass', + animationStartTime: 1, + animationEndTime: 3, + assStyle: 'Karaoke', + }, + ]); +}); + +test('parseSubtitleCues ignores timed comments without a matching animated dialogue cluster', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Comment: 0,0:00:01.00,0:00:03.00,Dial_JP,,0,0,0,,編集メモ', + 'Comment: 0,0:00:04.00,0:00:06.00,Dial_JP,,0,0,0,,別案の字幕', + 'Dialogue: 0,0:00:01.00,0:00:03.00,Dial_JP,,0,0,0,,通常の字幕', + 'Dialogue: 0,0:00:04.00,0:00:06.00,Dial_JP,,0,0,0,,別案の字幕', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + + assert.deepEqual(cues, [ + { startTime: 1, endTime: 3, text: '通常の字幕' }, + { startTime: 4, endTime: 6, text: '別案の字幕' }, + ]); +}); + +test('parseAssCues returns recovered canonical cues in chronological order', () => { + // Recovery appends recovered cues after surviving dialogue; the bare parseAssCues + // export must still come back time-ordered. + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:06.00,0:00:08.00,Dial,,0,0,0,,あとのセリフ', + 'Comment: 0,0:00:01.20,0:00:03.80,OP,,0,0,0,,雨が上がっても', + 'Dialogue: 0,0:00:01.20,0:00:01.24,OP,,0,0,0,,{\\pos(1,1)\\clip(m 1 1)}雨が上がっても', + 'Dialogue: 0,0:00:01.24,0:00:01.28,OP,,0,0,0,,{\\pos(1,1)\\clip(m 2 2)}雨が上がっても', + 'Dialogue: 0,0:00:01.28,0:00:03.80,OP,,0,0,0,,{\\pos(1,1)\\clip(m 3 3)}雨が上がっても', + ].join('\n'); + + assert.deepEqual( + parseAssCues(content).map((cue) => cue.startTime), + [1.2, 6], + ); +}); + +test('parseSubtitleCues withdraws a recovery whose owner is claimed by a later candidate', () => { + // The exit boundary event appears first in the file and recovers a canonical cue from + // its own small cluster. The entrance candidate then proves that exit event was a + // generated frame of the full animation; the earlier recovery is a duplicate of the + // same authored line and must not survive alongside it. + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 1,0:00:14.00,0:00:14.20,ED,,0,0,0,,{\\move(100,200,20,30)}ABCDEFGH', + 'Dialogue: 1,0:00:13.50,0:00:14.50,ED,,0,0,0,,{\\t(0,300,\\c&HFFFFFF&)}ABC', + 'Dialogue: 1,0:00:13.50,0:00:14.50,ED,,0,0,0,,{\\t(300,600,\\c&HFFFFFF&)}DEF', + 'Dialogue: 1,0:00:13.50,0:00:14.50,ED,,0,0,0,,{\\t(600,900,\\c&HFFFFFF&)}GH', + 'Dialogue: 0,0:00:10.00,0:00:10.20,ED,,0,0,0,,{\\move(10,20,100,200)}ABCDEFGH', + 'Dialogue: 0,0:00:10.00,0:00:12.00,ED,,0,0,0,,{\\t(0,300,\\fscx100)}ABC', + 'Dialogue: 0,0:00:10.00,0:00:12.00,ED,,0,0,0,,{\\t(300,600,\\fscx100)}DEF', + 'Dialogue: 0,0:00:10.00,0:00:13.40,ED,,0,0,0,,{\\t(600,900,\\fscx100)}GH', + ].join('\n'); + + assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [ + { + startTime: 10, + endTime: 14.2, + text: 'ABCDEFGH', + source: 'canonical-ass', + animationStartTime: 10, + animationEndTime: 14.2, + }, + ]); +}); + +test('parseSubtitleCues recovers canonical comments from generated clip frames', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Comment: 0,0:00:01.00,0:00:03.00,OP_JP,,0,0,0,,雨が上がっても', + 'Dialogue: 0,0:00:01.00,0:00:01.04,OP_JP,,0,0,0,,{\\pos(960,1068)\\clip(m 1 1)}雨が上がっても', + 'Dialogue: 0,0:00:01.04,0:00:01.08,OP_JP,,0,0,0,,{\\pos(960,1068)\\clip(m 2 2)}雨が上がっても', + 'Dialogue: 0,0:00:01.08,0:00:03.00,OP_JP,,0,0,0,,{\\pos(960,1068)\\clip(m 3 3)}雨が上がっても', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + + assert.deepEqual(cues, [ + { + startTime: 1, + endTime: 3, + text: '雨が上がっても', + source: 'canonical-ass', + animationStartTime: 1, + animationEndTime: 3, + }, + ]); +}); + test('parseSubtitleCues collapses tag-less animation frames in converted SRT', () => { // ASS -> SRT conversion drops override tags, so only the ~0.04s frame timing remains. const lines = ['1', '00:00:07,870 --> 00:00:07,910', 'Kaguya Wants to be Confessed to', '']; @@ -660,3 +1050,1055 @@ test('parseSubtitleCues detects subtitle formats from remote URLs', () => { assert.equal(cues.length, 1); assert.equal(cues[0]!.text, 'URLテスト'); }); + +test('parseSubtitleCues skips zero-duration ASS metadata events', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:00.00,0:00:00.00,Default,,0,0,0,,[Script Info]', + 'Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,Real subtitle', + ].join('\n'); + + assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [ + { startTime: 1, endTime: 2, text: 'Real subtitle' }, + ]); +}); + +test('parseSubtitleCues drops malformed ASS spacer reset debris', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:02.00,Background,,0,0,0,,{\\pos(10,10)}\\h\\h\\h\\{\\fr0', + 'Dialogue: 1,0:00:01.00,0:00:02.00,Default,,0,0,0,,Visible line', + ].join('\n'); + + assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [ + { startTime: 1, endTime: 2, text: 'Visible line' }, + ]); +}); + +test('parseSubtitleCues recovers spaces encoded only by positioned Latin glyph gaps', () => { + const glyphs = [ + ['T', 100], + ['h', 118], + ['e', 136], + ['s', 164], + ['t', 178], + ['a', 194], + ['r', 210], + ['s', 227], + ['I', 255], + ['s', 275], + ['e', 293], + ['e', 311], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + glyphs.map( + ([glyph, x], index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'The stars I see'); +}); + +test('parseSubtitleCues does not split narrow letters inside positioned English words', () => { + const text = 'carryinghappiness'; + const positions = [ + 323, 341, 356, 369, 383, 396, 410, 428, 456, 474, 493, 512, 526, 540, 558, 575, 590, + ]; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + [...text].map( + (glyph, index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${positions[index]},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'carrying happiness'); +}); + +test('parseSubtitleCues keeps proportional-font variation inside positioned English words', () => { + const text = 'sendsripplesacrossthestillnessofyourheart'; + const positions = [ + 32, 46, 60, 78, 95, 121, 131, 144, 163, 177, 189, 203, 232, 248, 261, 274, 288, 302, 329, 346, + 363, 390, 405, 416, 423, 432, 443, 457, 471, 485, 512, 525, 551, 564, 579, 593, 622, 639, 655, + 670, 684, + ]; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + [...text].map( + (glyph, index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,Insert English,,0,0,0,,{\\pos(${positions[index]},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`, + ), + ), + ].join('\n'); + + assert.equal( + parseSubtitleCues(content, 'test.ass')[0]?.text, + 'sends ripples across the stillness of your heart', + ); +}); + +test('parseSubtitleCues keeps a short capitalized word when the following gap is larger', () => { + const text = 'IfIgrow'; + const positions = [347, 365, 397, 430, 446, 463, 485]; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + [...text].map( + (glyph, index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,Insert English,,0,0,0,,{\\pos(${positions[index]},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'If I grow'); +}); + +// Geometry taken from a real per-glyph ED line. The `waves within` gap crosses a wide +// `w`, so the width-normalized ratio reads it as a common advance; only the constant +// extra distance of the authored word space gives it away. +test('parseSubtitleCues recovers a word gap measured across a wide glyph', () => { + const text = 'youcanhearthesoundofthewaveswithinmyheart'; + const positions = [ + 202, 223, 244, 274, 296, 317, 346, 367, 389, 408, 433, 450, 470, 499, 517, 538, 557, 578, 609, + 627, 651, 668, 688, 723, 748, 770, 792, 811, 843, 863, 875, 892, 907, 922, 957, 983, 1013, 1034, + 1055, 1075, 1090, + ]; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + [...text].map( + (glyph, index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,ED English,,0,0,0,,{\\pos(${positions[index]},687)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`, + ), + ), + ].join('\n'); + + assert.equal( + parseSubtitleCues(content, 'test.ass')[0]?.text, + 'you can hear the sound of the waves within my heart', + ); +}); + +// A single short word gives too few gap samples to trust the excess rule: its narrow +// glyphs skew the common advance low and `w e` would read as a word gap. +test('parseSubtitleCues does not split a short single positioned word', () => { + const text = 'Swelling'; + const positions = [592, 613, 635, 647, 655, 662, 673, 689]; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + [...text].map( + (glyph, index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,ED English,,0,0,0,,{\\pos(${positions[index]},682)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'Swelling'); +}); + +// A capitalized word whose first letter sits before a wide glyph (`S|miles`) overruns +// the width table; the excess rule must not split a capital from its lowercase run. +test('parseSubtitleCues keeps a capitalized word intact under the excess rule', () => { + const text = 'Smilesarebudding'; + const positions = [37, 63, 80, 88, 99, 113, 142, 157, 171, 201, 217, 235, 255, 269, 280, 295]; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + [...text].map( + (glyph, index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,ED English,,0,0,0,,{\\pos(${positions[index]},682)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'Smiles are budding'); +}); + +// Mirrors a real ED: per-syllable romaji at y=34 overlaid with animated single letters +// at y=29 rendered through `\fn` in a symbol font, where `a` draws as a sparkle. The +// letters must neither join the reconstructed line nor survive as their own cues. +test('parseSubtitleCues drops symbol-font glyph decoration from a reconstructed line', () => { + const syllables = [ + ['so', 479], + ['t', 505], + ['to', 529], + ['mi', 577], + ['mi', 618], + ['ni', 663], + ['a', 699], + ['te', 728], + ['ru', 764], + ['to', 810], + ] as const; + const decoration = [ + ['a', 479, '0:00:01.25'], + ['z', 577, '0:00:02.51'], + ['x', 618, '0:00:02.78'], + ['q', 505, '0:00:04.20'], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + syllables.map( + ([syllable, x], index) => + `Dialogue: ${layer},0:00:01.00,0:00:05.37,ED Romaji,,0,0,0,fx,{\\an5\\pos(${x},34)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${syllable}`, + ), + ), + ...decoration.map( + ([glyph, x, start]) => + `Dialogue: 0,${start},0:00:05.37,ED Romaji,,0,0,0,fx,{\\pos(${x},29)\\fnSplit splat splodge\\fs28\\t(3870,3970,\\fscx105)}${glyph}`, + ), + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + assert.equal(cues.length, 1); + assert.equal(cues[0]?.text, 'sotto mimi ni ateru to'); +}); + +test('parseSubtitleCues drops clipped repeated-glyph texture text', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + "Dialogue: 10,0:00:01.00,0:00:04.00,Default,,0,0,0,,I'm blocking them.", + 'Dialogue: 2,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,80)\\fnSerangkaian Pattern Regular\\clip(800,20,1120,140)}LLLLLLLLLLLLLLLLLLLLLLLL', + 'Dialogue: 3,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,150)\\fnSF Pro Display}Enter a message', + ].join('\n'); + + assert.deepEqual( + parseSubtitleCues(content, 'test.ass').map((cue) => cue.text), + ["I'm blocking them.", 'Enter a message'], + ); +}); + +test('parseSubtitleCues preserves opaque same-font text beside texture fragments', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 2,0:00:01.00,0:00:04.00,MarySigns,seed,0,0,0,,{\\pos(960,80)\\fnSerangkaian Pattern Regular\\clip(800,20,1120,140)}LLLLLLLLLLLLLLLLLLLLLLLL', + 'Dialogue: 2,0:00:01.00,0:00:04.00,MarySigns,piece,0,0,0,,{\\pos(960,110)\\fnSerangkaian Pattern Regular\\clip(800,20,1120,140)}LLLL', + 'Dialogue: 3,0:00:01.00,0:00:04.00,MarySigns,label,0,0,0,,{\\pos(960,150)\\fnSerangkaian Pattern Regular}Keep this label', + ].join('\n'); + + assert.deepEqual( + parseSubtitleCues(content, 'test.ass').map((cue) => cue.text), + ['Keep this label'], + ); +}); + +test('parseSubtitleCues drops tiny alpha payloads from a proven texture font', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 2,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain Medium\\clip(500,40,660,150)}LLLLLLLLLLLL', + "Dialogue: 1,0:00:06.00,0:00:09.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain\\fs10\\alpha&H70&}q26D'vrA;\\NE? GS\\NESLhlawEv", + 'Dialogue: 3,0:00:06.00,0:00:09.00,FrogSigns,,0,0,0,,{\\pos(1040,620)\\fnSF Pro Display\\fs66}Waiting!', + ].join('\n'); + + assert.deepEqual( + parseSubtitleCues(content, 'test.ass').map((cue) => cue.text), + ['Waiting!'], + ); +}); + +test('parseSubtitleCues preserves a small multiline translation using an unverified transparent font', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 2,0:00:01.00,0:00:04.00,Transition,,0,0,0,,{\\pos(580,95)\\fnPhone UI\\fs60\\alpha&HF0&}Faded transition', + 'Dialogue: 3,0:00:06.00,0:00:09.00,Phone,,0,0,0,,{\\pos(1040,620)\\fnPhone UI\\fs10\\alpha&H70&}Call me when you arrive.\\NI will still be awake.\\NDo not rush.', + ].join('\n'); + + assert.deepEqual( + parseSubtitleCues(content, 'test.ass').map((cue) => cue.text), + ['Faded transition', 'Call me when you arrive.\nI will still be awake.\nDo not rush.'], + ); +}); + +test('parseSubtitleCues drops clipped repeated-glyph texture text without a font override', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\an7\\pos(736.49,152.99)\\fscy150\\fs10\\bord3\\c&H657BC8&\\3c&H657BC8&\\blur3\\clip}lllllllllllll', + 'Dialogue: 0,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\an7\\pos(769.9,106.18)\\fscy150\\fs12\\bord3\\c&H66729F&\\3c&H66729F&\\blur5\\clip}llll', + 'Dialogue: 5,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(893,311)}Read', + ].join('\n'); + + assert.deepEqual( + parseSubtitleCues(content, 'test.ass').map((cue) => cue.text), + ['Read'], + ); +}); + +test('parseSubtitleCues drops per-character alpha texture text', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + "Dialogue: 10,0:00:01.00,0:00:04.00,Default,Girl,0,0,0,,So Doloris was actually Uika-chan from sumimi! That's amazing!", + "Dialogue: 2,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,240)\\fnCinzel}Hanasakigawa Girl's School", + 'Dialogue: 3,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,300)\\fnSplit splat splodge\\clip(800,200,1120,400)}d{\\2a1}s{\\2a0}h{\\2a1}f{\\2a0}k{\\2a1}h{\\2a0}f{\\2a1}s{\\2a0}d{\\2a1}f{\\2a0}e', + 'Dialogue: 3,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(980,340)\\fnSplit splat splodge}f {\\2a1}a', + 'Dialogue: 4,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,360)\\fnGrain SemiBold}5{\\2a1}X{\\2a0}N{\\2a1}T{\\2a0}f{\\2a1}I{\\2a0}g{\\2a1}F{\\2a0}B{\\2a1}?{\\2a0}k{\\2a1}u{\\2a0}C{\\2a1}m', + ].join('\n'); + + assert.deepEqual( + parseSubtitleCues(content, 'test.ass').map((cue) => cue.text), + [ + "So Doloris was actually Uika-chan from sumimi! That's amazing!", + "Hanasakigawa Girl's School", + ], + ); +}); + +test('parseSubtitleCues drops transparent texture payloads across an animated sign', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + "Dialogue: 90,0:00:01.00,0:00:04.00,Alt,,0,0,0,,Even if you want to see her, she doesn't want to see you!", + 'Dialogue: 0,0:00:01.00,0:00:01.08,FrogSigns,,0,0,0,,{\\pos(699,803)\\fnSerangkaian Pattern Regular\\clip(300,380,1130,1050)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a0}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\\\\\\\\\\\\\\\\\\\\\', + 'Dialogue: 3,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnGrain\\alpha&HE0&}Street performance by Mortis from\\NMujica - Acting prodigy in action!', + 'Dialogue: 5,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnRoboto Medium\\alpha&H00&}Street performance by Mortis from\\NMujica - Acting prodigy in action!', + 'Dialogue: 6,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnGrain\\alpha&HE0&}H1.4igcAhGYHVWD"kHcVlG2W9eKEWj"!X\\N\'uNVaEVpTXMd9rk7dnRX\'P!RhsS"Wn90k6', + 'Dialogue: 6,0:00:01.00,0:00:01.08,FrogSigns,18K,0,0,0,,{\\pos(284,821)\\fnGrain\\alpha&HE0&}ou:QepiiPqQ.4n.IYbFaGHtPzWyKI9CUSq:', + 'Dialogue: 1,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(581,921)\\fnSerangkaian Pattern Regular\\clip(195,495,986,1120)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{', + 'Dialogue: 3,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnGrain\\alpha&HE0&}Street performance by Mortis from\\NMujica - Acting prodigy in action!', + 'Dialogue: 5,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnRoboto Medium\\alpha&H00&}Street performance by Mortis from\\NMujica - Acting prodigy in action!', + 'Dialogue: 3,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnGrain\\alpha&HF0&}9LF\'GpPCTlOkLxBLV:QN,8R8NUVM"ha.s\\NNUUPNTBdJih4jUthK34i,yYe;9EBgLXbET', + "Dialogue: 6,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(150,936)\\fnGrain\\alpha&HE0&}JS7vl:lD;'PzkCb!bGT;.7TbA.KCkEH0LOk", + ].join('\n'); + + assert.deepEqual( + parseSubtitleCues(content, 'test.ass').map((cue) => cue.text), + [ + 'Street performance by Mortis from\nMujica - Acting prodigy in action!', + "Even if you want to see her, she doesn't want to see you!", + 'Street performance by Mortis from\nMujica - Acting prodigy in action!', + ], + ); +}); + +test('parseSubtitleCues does not reconstruct short texture pieces under another actor', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,bubble,0,0,0,,{\\pos(245,-102)\\fnSerangkaian Pattern Regular\\clip(224,-1,831,106)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L', + 'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(917,293)\\alpha&H20&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L', + 'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(911,293)\\alpha&H58&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L', + 'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(845,300)\\alpha&H00&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L', + 'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(907,293)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,289,1010,338)}L{\\2a0}L{\\2a1}L{\\2a0}L', + 'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(911,293)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,289,1010,338)}L{\\2a0}L{\\2a1}L{\\2a0}L', + 'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(922,130)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,120,1010,173)}L{\\2a0}L{\\2a1}L{\\2a0}L', + 'Dialogue: 5,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(893,311)\\fnSFProDisplay-Regular-STR}Read 3', + ].join('\n'); + + assert.deepEqual( + parseSubtitleCues(content, 'test.ass').map((cue) => cue.text), + ['Read 3'], + ); +}); + +test('parseSubtitleCues separates overlapping positioned English lyric sequences', () => { + const fragments = [ + ['my', 642, '0:00:01.00', '0:00:04.05'], + ['song!', 713, '0:00:01.00', '0:00:04.05'], + ['I', 533, '0:00:01.67', '0:00:04.09'], + ['h', 557, '0:00:01.67', '0:00:04.09'], + ['u', 575, '0:00:01.67', '0:00:04.09'], + ['m', 597, '0:00:01.67', '0:00:04.09'], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + fragments.map( + ([text, x, start, end], index) => + `Dialogue: ${layer},${start},${end},OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${text}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'my song! I hum'); +}); + +const eventsHeader = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', +]; + +test('parseSubtitleCues keeps tall CC-style base dialogue publishable after removing furigana', () => { + const content = [ + ...eventsHeader, + 'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(212,383)\\fscx50\\fscy50}たき', + 'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(172,437)\\fscx50}({\\fscx100}立希{\\fscx50})', + 'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(332,443)\\fscx50\\fscy50}ともり', + 'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(192,497)}お前…{\\fscx50} {\\fscx100}燈をバンドに誘ったの?', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + assert.deepEqual( + cues.map((cue) => cue.text), + ['(立希)', 'お前… 燈をバンドに誘ったの?'], + ); + assert.deepEqual(cues[0]?.assFurigana, ['たき']); + assert.deepEqual(cues[1]?.assFurigana, ['ともり']); + assert.ok(cues.every((cue) => cue.assLayout?.kind === 'positioned')); +}); + +test('parseSubtitleCues removes half-size positioned furigana from broadcast captions', () => { + const content = [ + '[Script Info]', + 'PlayResY: 540', + '', + ...eventsHeader, + 'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(192,77)\\fscx50}({\\fscx100}山田{\\fscx50}){\\fscx100}ごめん{\\fscx50} {\\fscx100}結局{\\fscx50} {\\fscx100}ぬれたな{\\fscx50}。', + 'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,113)\\fscx50\\fscy50}だいじょうぶ', + 'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)}大丈夫{\\fscx50}。', + 'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(232,407)\\fscx50}({\\fscx100}山田の母{\\fscx50}){\\fscx100}ほんなら', + 'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(232,443)\\fscx50\\fscy50}かく', + 'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(312,443)\\fscx50\\fscy50}ちょぞう', + 'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(232,497)}隠し貯蔵のミルクまんじゅう➡', + 'Dialogue: 0,0:04:00.00,0:04:03.00,Default,,0,0,0,,{\\pos(232,443)\\fscx50\\fscy50}ぜったい ちが', + 'Dialogue: 0,0:04:00.00,0:04:03.00,Default,,0,0,0,,{\\pos(232,497)}絶対違う', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'polar-opposites-s02e08.ass'); + + assert.deepEqual( + cues.map((cue) => cue.text), + [ + '(山田)ごめん 結局 ぬれたな。', + '大丈夫。', + '(山田の母)ほんなら', + '隠し貯蔵のミルクまんじゅう➡', + '絶対違う', + ], + ); + assert.deepEqual(cues[1]?.assFurigana, ['だいじょうぶ']); + assert.deepEqual(cues[3]?.assFurigana, ['かく', 'ちょぞう']); + assert.deepEqual(cues[4]?.assFurigana, ['ぜったい ちが']); +}); + +test('parseSubtitleCues scales furigana geometry by PlayResY', () => { + const content = [ + '[Script Info]', + 'PlayResY: 1080', + '', + ...eventsHeader, + 'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(1104,226)\\fscx50\\fscy50}だいじょうぶ', + 'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(1104,334)}大丈夫{\\fscx50}。', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + assert.deepEqual( + cues.map((cue) => cue.text), + ['大丈夫。'], + ); + assert.deepEqual(cues[0]?.assFurigana, ['だいじょうぶ']); +}); + +test('parseSubtitleCues preserves small kana without a matching kanji base caption', () => { + const content = [ + ...eventsHeader, + 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(200,200)\\fscx50\\fscy50}ひそひそ', + 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(200,254)}ordinary dialogue', + ].join('\n'); + + assert.deepEqual( + parseSubtitleCues(content, 'test.ass').map((cue) => cue.text), + ['ひそひそ', 'ordinary dialogue'], + ); +}); + +test('parseSubtitleCues preserves small kana horizontally separated from a kanji caption', () => { + const content = [ + ...eventsHeader, + 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(800,200)\\fscx50\\fscy50}ひそひそ', + 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(200,254)}漢字', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + assert.match(cues.map((cue) => cue.text).join('\n'), /ひそひそ/); + assert.deepEqual( + cues.flatMap((cue) => cue.assFurigana ?? []), + [], + ); +}); + +test('parseSubtitleCues marks re-shown countdown frames as a fragment grid', () => { + const rows = [ + ['juu', '10'], + ['juu', '10'], + ['kyuu', '9'], + ['kyuu', '9'], + ['hachi', '8'], + ['hachi', '8'], + ] as const; + const content = [ + ...eventsHeader, + ...rows.flatMap(([word, num], index) => { + const timestamp = (seconds: number) => `0:00:${seconds.toFixed(2).padStart(5, '0')}`; + const start = timestamp(6 + index * 0.4); + const end = timestamp(6 + index * 0.4 + 0.4); + return [0, 1].flatMap((layer) => [ + `Dialogue: ${layer},${start},${end},ED Romaji,,0,0,0,,{\\pos(${300 + index * 8},40)\\t(0,100,\\fscx120)}${word}`, + `Dialogue: ${layer},${start},${end},ED Romaji,,0,0,0,,{\\pos(${300 + index * 8},93)\\t(0,100,\\fscx120)}${num}`, + ]); + }), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.assLayout?.kind, 'fragment-grid'); +}); + +test('parseSubtitleCues marks scattered single-glyph typesetting as a fragment grid', () => { + const glyphs = ['の', 'こ', '部', 'そ', '屋']; + const content = [ + ...eventsHeader, + ...[0, 1].flatMap((layer) => + glyphs.map( + (glyph, index) => + `Dialogue: ${layer},0:00:06.00,0:00:09.00,OP-JP,,0,0,0,,{\\pos(${500 + index * 30},${-30 + index * 35})\\t(0,100,\\fscx120)}${glyph}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.assLayout?.kind, 'fragment-grid'); +}); + +test('parseSubtitleCues marks a repeated-token sign wall as a fragment grid', () => { + const content = [ + ...eventsHeader, + ...[0, 1].flatMap((layer) => + Array.from( + { length: 6 }, + (_, index) => + `Dialogue: ${layer},0:00:06.00,0:00:09.00,Sign,,0,0,0,,{\\pos(${200 + index * 60},${100 + index * 30})\\t(0,100,\\fscx120)}${index % 2 === 0 ? 'Maid' : 'Cafe'}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.assLayout?.kind, 'fragment-grid'); +}); + +test('parseSubtitleCues keeps a wrapped lyric with a staggered repeated token publishable', () => { + const fragments = [ + ['dreams', 300, 115, '0:00:01.00'], + ['ju', 250, 39, '0:00:01.00'], + ['n', 280, 39, '0:00:01.00'], + ['jo', 300, 39, '0:00:01.00'], + ['u', 330, 39, '0:00:01.00'], + ['to', 360, 39, '0:00:01.00'], + ['jo', 395, 39, '0:00:01.02'], + ['u', 425, 39, '0:00:01.00'], + ['ne', 455, 39, '0:00:01.00'], + ['tsu!', 485, 39, '0:00:01.00'], + ] as const; + const content = [ + ...eventsHeader, + ...[0, 1].flatMap((layer) => + fragments.map( + ([text, x, y, start], index) => + `Dialogue: ${layer},${start},0:00:04.00,ED Romaji,,0,0,0,,{\\pos(${x},${y})\\t(${index * 2},${index * 2 + 100},\\fscx120)}${text}`, + ), + ), + ].join('\n'); + + const cue = parseSubtitleCues(content, 'test.ass')[0]; + assert.notEqual(cue?.assLayout?.kind, 'fragment-grid'); +}); + +test('parseSubtitleCues adds a missing word space after positioned punctuation', () => { + const fragments = [ + ['H', 100], + ['i,', 119], + ['t', 153], + ['h', 168], + ['e', 186], + ['r', 202], + ['e', 216], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + fragments.map( + ([fragment, x], index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'Hi, there'); +}); + +test('parseSubtitleCues does not split a positioned thousands separator', () => { + const fragments = [ + ['1,', 100], + ['000', 145], + ['0', 185], + ['0', 205], + ['0', 225], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + fragments.map( + ([fragment, x], index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, '1,000000'); +}); + +test('parseSubtitleCues does not split a wide glyph from its punctuated suffix', () => { + const fragments = [ + ['v', 904], + ['o', 924], + ['i', 939], + ['c', 955], + ['e', 976], + ['r', 1004], + ['e', 1021], + ['a', 1042], + ['c', 1063], + ['h', 1083], + ['e', 1104], + ['d', 1125], + ['m', 1161], + ['e,', 1193], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + fragments.map( + ([fragment, x], index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'voice reached me,'); +}); + +test('parseSubtitleCues spaces positioned lyric fragments across authored rows', () => { + const fragments = [ + ['My', 472, 39], + ['song!', 543, 39], + ['My', 507, 78], + ['song!', 578, 78], + ['ku', 643, 39], + ['chi', 683, 39], + ['zu', 722, 39], + ['sa', 757, 39], + ['n', 783, 39], + ['de', 811, 39], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + fragments.map( + ([fragment, x, y], index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,OP Romaji,,0,0,0,,{\\pos(${x},${y})\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'My song! My song! kuchizusande'); +}); + +test('parseSubtitleCues recovers positioned word gaps between romaji fragments', () => { + const fragments = [ + ['sa', 380], + ['ga', 421], + ['shi', 467], + ['te', 510], + ['ta', 545], + ['ha', 593], + ['ji', 624], + ['ke', 655], + ['ta', 693], + ['i', 726], + ['ro', 749], + ['no', 798], + ['yu', 849], + ['me', 895], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + fragments.map( + ([fragment, x], index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,OP Romaji,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'sagashiteta hajiketa iro no yume'); +}); + +test('parseSubtitleCues recovers clear word gaps in a short romaji line', () => { + const fragments = [ + ['bo', 542], + ['ku', 584], + ['wo', 640], + ['yo', 697], + ['bu', 738], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + fragments.map( + ([fragment, x], index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,OP Romaji,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'boku wo yobu'); +}); + +test('parseSubtitleCues suppresses a karaoke highlight sweep without publishing it', () => { + // Main lyric: per-glyph fragments alive together for the whole line. + const lineFragments = [ + ['to', 972], + ['so', 1051], + ['u', 1113], + ['o', 1166], + ['mo', 1204], + ] as const; + // Highlight sweep: one syllable at a time over the same lyric, each event ending + // exactly as the next begins, so no two syllables are ever on screen together. + const sweepFragments = [ + ['to', 972, '0:00:01.00', '0:00:01.40'], + ['so', 1051, '0:00:01.40', '0:00:01.80'], + ['u', 1113, '0:00:01.80', '0:00:02.20'], + ['o', 1166, '0:00:02.20', '0:00:02.60'], + ['mo', 1204, '0:00:02.60', '0:00:03.00'], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...[0, 1].flatMap((layer) => + lineFragments.map( + ([fragment, x], index) => + `Dialogue: ${layer},0:00:01.00,0:00:04.00,ED Romaji,,0,0,0,fx,{\\pos(${x},60)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`, + ), + ), + ...sweepFragments.flatMap(([fragment, x, start, end]) => + [ + [40, x, 60], + [41, x + 4, 64], + ].map( + ([layer, copyX, copyY]) => + `Dialogue: ${layer},${start},${end},ED Romaji2,,0,0,0,fx,{\\an5\\pos(${copyX},${copyY})\\t(150,290,\\1a&HFF&)}${fragment}`, + ), + ), + 'Dialogue: 42,0:00:01.20,0:00:01.30,ED Romaji2,,0,0,0,fx,{\\fnWebdings\\pos(900,50)\\t(0,100,\\fscx120)}a', + 'Dialogue: 42,0:00:04.00,0:00:04.20,ED Romaji2,,0,0,0,fx,{\\fnWebdings\\pos(900,50)\\t(0,100,\\fscx120)}z', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + assert.equal(cues.length, 2); + assert.equal(cues[0]?.text.replace(/\s+/gu, ''), 'tosouomo'); + assert.equal(cues[1]?.text, 'z'); +}); + +test('parseSubtitleCues collapses drop-shadow layer copies offset by a few pixels', () => { + const fragments = [ + ['me', 580], + ['no', 668], + ['mae', 770], + ['ni', 864], + ['no', 939], + ['bi', 996], + ['ru', 1049], + ] as const; + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...fragments.flatMap(([fragment, x], index) => [ + `Dialogue: 30,0:01:42.00,0:01:46.92,OP Romaji,,0,0,0,fx,{\\pos(${x},25)\\bord0\\t(${index * 2},${index * 2 + 120},\\blur0.5)}${fragment}`, + // Shadow copy sits 4px off the base glyph and must not read as a second syllable. + `Dialogue: 29,0:01:42.00,0:01:46.92,OP Romaji,,0,0,0,fx,{\\pos(${x + 4},29)\\c&HFFFFFF&\\t(${index * 2},${index * 2 + 120},\\blur9)}${fragment}`, + `Dialogue: 28,0:01:42.00,0:01:46.92,OP Romaji,,0,0,0,fx,{\\pos(${x},25)\\c&HFFFFFF&\\t(${index * 2},${index * 2 + 120},\\blur9)}${fragment}`, + ]), + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + assert.equal(cues.length, 1); + assert.equal(cues[0]?.text.replace(/\s+/gu, ''), 'menomaeninobiru'); +}); + +test('parseSubtitleCues recovers positional word gaps beside an authored space', () => { + // Real ED line: every glyph is placed by `\move`, but the `star` fragment alone carries + // a literal leading space. The authored space must not disable positional recovery for + // the rest of the line. + const fragments = [ + ['s', 633], + ['e', 665], + ['a', 697], + ['r', 723], + ['c', 747], + ['h', 774], + ['i', 793], + ['n', 813], + ['g', 838], + ['f', 884], + ['o', 911], + ['r', 937], + ['a', 986], + ['s', 1041], + ['h', 1070], + ['o', 1098], + ['o', 1128], + ['t', 1153], + ['i', 1169], + ['n', 1188], + ['g', 1214], + [' s', 1264], + ['t', 1290], + ['a', 1316], + ['r', 1342], + ] as const; + const content = [ + ...eventsHeader, + ...[0, 1].flatMap((layer) => + fragments.map( + ([fragment, x], index) => + `Dialogue: ${layer},0:22:44.83,0:22:47.70,ED English,,0,0,0,fx,{\\move(${x},1020,${x},1020,0,300)\\t(${index * 2},${index * 2 + 300},\\fs90)}${fragment}`, + ), + ), + ].join('\n'); + + assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'searching for a shooting star'); +}); + +test('parseSubtitleCues splits chunked words whose gap only the excess rule catches', () => { + // `Choices|presumably` normalizes to just under the ratio threshold because both + // neighbors are wide three-letter chunks; its constant word-space excess still shows. + const fragments = [ + ['Ch', 526], + ['oi', 584], + ['ces', 648], + ['pre', 747], + ['su', 819], + ['mab', 904], + ['ly', 981], + ['ma', 1059], + ['de', 1128], + ['by', 1203], + ['cha', 1295], + ['nce', 1385], + ] as const; + const content = [ + ...eventsHeader, + ...[0, 1].flatMap((layer) => + fragments.map( + ([fragment, x], index) => + `Dialogue: ${layer},0:01:53.01,0:01:55.52,OP English,,0,0,0,fx,{\\pos(${x},1055)\\t(${index * 2},${index * 2 + 120},\\blur0.5)}${fragment}`, + ), + ), + ].join('\n'); + + assert.equal( + parseSubtitleCues(content, 'test.ass')[0]?.text, + 'Choices presumably made by chance', + ); +}); + +test('parseSubtitleCues rebuilds a line from per-glyph phase stacks with staggered timing', () => { + // Each glyph lives as four events anchored at one point: a transparent pre-echo until + // its syllable is sung, a short highlight, a rising exit ghost, and a steady hold. + // No timing window is shared across the phases, only the anchor ties them together. + const glyphs = [ + ['エ', 559], + ['ネ', 601], + ['ル', 643], + ['ギ', 686], + ['ー', 728], + ] as const; + const timestamp = (seconds: number) => `0:00:${seconds.toFixed(2).padStart(5, '0')}`; + const content = [ + ...eventsHeader, + ...glyphs.flatMap(([glyph, x], index) => { + const highlightStart = 20.9 + index * 0.4; + return [ + `Dialogue: 3,${timestamp(20 + index * 0.03)},${timestamp(highlightStart)},OP - JP,,0,0,0,,{\\blur1.5\\bord2\\c&H404040&\\3c&HFFFFFF&\\an5\\pos(${x},50)\\fad(300,0)\\1a&HFF&}${glyph}`, + `Dialogue: 3,${timestamp(highlightStart)},${timestamp(highlightStart + 0.4)},OP - JP,,0,0,0,,{\\an5\\pos(${x},50)\\bord2\\c&H404040&\\3c&HFFFFFF&\\t(120,240,\\3c&H007A7A7A&\\blur0)\\fad(0,300)}${glyph}`, + `Dialogue: 3,${timestamp(highlightStart)},${timestamp(highlightStart + 2.4)},OP - JP,,0,0,0,,{\\an5\\move(${x},50,${x},0)\\bord0\\shad0\\t(\\c&HFFFFFF&\\blur5\\alpha&HFF&)}${glyph}`, + `Dialogue: 3,${timestamp(highlightStart + 0.4)},${timestamp(26.1 + index * 0.05)},OP - JP,,0,0,0,,{\\an5\\pos(${x},50)\\bord2\\c&H404040&\\3c&HFFFFFF&\\fad(0,300)}${glyph}`, + ]; + }), + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + assert.equal(cues.length, 1); + assert.equal(cues[0]?.text, 'エネルギー'); + assert.equal(cues[0]?.source, 'reconstructed-ass'); + // Published from the first sung syllable to the end of the hold, so the transparent + // lead-in and the exit ghosts' fade tail never overlap the neighboring lines. The + // full generated span stays available as the animation window. + assert.equal(cues[0]?.startTime, 20.9); + assert.equal(cues[0]?.endTime, 26.3); + assert.equal(cues[0]?.animationStartTime, 20); + assert.equal(cues[0]?.animationEndTime, 26.3); +}); + +test('parseSubtitleCues drops transparent glow echoes and merges an exit replay', () => { + // The visible line sits at one row while transparent-fill glow copies duplicate every + // glyph on another row, and the exit shatters each glyph into copies launched from a + // shared anchor. Only the authored line may publish, as a single unbroken cue. + const glyphs = [ + ['さ', 686], + ['あ', 728], + ['預', 770], + ['け', 812], + ['て', 854], + ] as const; + const content = [ + ...eventsHeader, + ...glyphs.flatMap(([glyph, x]) => [ + `Dialogue: 3,0:00:16.28,0:00:18.71,OP - JP,,0,0,0,,{\\an2\\pos(${x},85)\\fad(200,0)\\fry-90\\c&H404040&\\3c&HF4F4F4&\\bord2\\t(0,300,\\fry0)}${glyph}`, + ...[0, 1].map( + () => + `Dialogue: 3,0:00:16.28,0:00:20.01,OP - JP,,0,0,0,,{\\pos(${x},15)\\blur5.8\\fry-90\\1a&HFF&\\fad(200,0)\\3c&H3F26AA&\\t(0,300,\\fry0)\\t(2596,3222,\\bord0\\3a&HFF&)}${glyph}`, + ), + ...[0, 1].map( + (copy) => + `Dialogue: 3,0:00:18.71,0:00:20.89,OP - JP,,0,0,0,,{\\an5\\bord2\\fad(0,200)\\move(${x},50,${x + 25 + copy * 3},${17 - copy * 39},1605,2055)\\t(450,792,\\c&H3500DE&\\bord0)\\t(1605,2055,\\blur15\\fscx20\\fscy20\\1a&H50&\\3a&H50&)}${glyph}`, + ), + ]), + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + assert.equal(cues.length, 1); + assert.equal(cues[0]?.text, 'さあ預けて'); + assert.equal(cues[0]?.startTime, 16.28); + assert.equal(cues[0]?.endTime, 20.89); +}); + +test('parseSubtitleCues does not double a line rendered whole beside its glyph swarm', () => { + // An assembly effect shows the authored line as one positioned event while dozens of + // per-glyph particle copies converge onto each glyph's anchor. The whole event and + // the swarm spell the same text and must publish as one line, once. + const glyphs = [ + ['可', 854], + ['笑', 896], + ['し', 938], + ['い', 980], + ['わ', 1022], + ['ね', 1064], + ] as const; + const wholeLine = glyphs.map(([glyph]) => `{\\an5\\fad(300,500)\\pos(960,50)}${glyph}`).join(''); + const content = [ + ...eventsHeader, + `Dialogue: 1,0:00:17.29,0:00:18.99,OP - JP,,0,0,0,,${wholeLine}`, + ...glyphs.flatMap(([glyph, x], index) => + [0, 1, 2].map( + (copy) => + `Dialogue: 2,0:00:17.${30 + index * 5 + copy},0:00:19.10,OP - JP,,0,0,0,,{\\bord4\\blur4\\an5\\fad(500,0)\\move(${x - 60 - copy * 17},${120 + copy * 6},${x},50,20,900)\\clip(${x - 70},80,${x - 66},84)\\t(20,900,\\clip(${x - 4},7,${x},11))}${glyph}`, + ), + ), + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + assert.equal(cues.length, 1); + assert.equal(cues[0]?.text, '可笑しいわね'); +}); + +test('parseSubtitleCues drops a wall of near-invisible positioned texture strings', () => { + // An image drawn by \p1 vector events carries no texture seed, but its glyph payload + // is still dozens of near-transparent positioned strings sharing one window. A real + // faint translation is one or two events and stays published. + const content = [ + ...eventsHeader, + "Dialogue: 90,0:00:12.66,0:00:14.91,Default,,0,0,0,,We'll play as a band, and then...", + ...Array.from( + { length: 12 }, + (_, index) => + `Dialogue: 9,0:00:12.66,0:00:14.91,MarySigns,,0,0,0,,{\\an7\\pos(${640 + index * 13},${4 + index * 40})\\fnGrain SemiBold\\c&H000000&\\alpha&HFD&}gtO${index}x!`, + ), + 'Dialogue: 9,0:00:12.66,0:00:14.91,OtherSign,,0,0,0,,{\\pos(151,769)\\fnGrain\\alpha&HE0&}A faint but real translation', + ].join('\n'); + + assert.deepEqual( + parseSubtitleCues(content, 'test.ass').map((cue) => cue.text), + ["We'll play as a band, and then...", 'A faint but real translation'], + ); +}); + +test('parseSubtitleCues drops zero-scaled zero-clipped hidden warning text', () => { + const content = [ + ...eventsHeader, + 'Dialogue: 99,0:00:00.00,0:00:15.16,Default,,0,0,0,,{\\org(0,0)\\fscx0\\fscy0\\clip(0,0,0,0)}Your media player does not support the subtitle format.', + 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\fscx0\\t(0,300,\\fscx100)}見えるセリフ', + ].join('\n'); + + assert.deepEqual( + parseSubtitleCues(content, 'test.ass').map((cue) => cue.text), + ['見えるセリフ'], + ); +}); + +test('parseSubtitleCues keeps hidden events hidden when a transform animates an unrelated tag', () => { + // `\t(...)` only reveals a zero-scaled or fully clipped event when it animates the + // scale or the clip itself. Animating an unrelated property -- at any nesting depth -- + // leaves the event invisible, so its text must not reach the subtitles. + const content = [ + ...eventsHeader, + 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\fscx0\\fscy0\\clip(0,0,0,0)\\t(0,300,\\bord5)}hidden warning', + 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\clip(0,0,0,0)\\t(0,600,\\t(0,300,\\blur4))}nested hidden warning', + 'Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,{\\fscx0\\t(0,300,\\fscx100)}grows into view', + 'Dialogue: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,{\\clip(0,0,0,0)\\t(0,300,\\clip(0,0,500,500))}wipes into view', + ].join('\n'); + + assert.deepEqual( + parseSubtitleCues(content, 'test.ass').map((cue) => cue.text), + ['grows into view', 'wipes into view'], + ); +}); + +test('parseAssCues records the vertical band from style alignment, overrides, and \\pos', () => { + const ass = [ + '[Script Info]', + 'PlayResY: 720', + '', + '[V4+ Styles]', + 'Format: Name, Fontname, Fontsize, PrimaryColour, Bold, Alignment, MarginV, Encoding', + 'Style: Bottom,Arial,54,&H00FFFFFF,0,2,30,1', + 'Style: TopSong,Arial,54,&H00FFFFFF,0,9,12,1', + '', + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:03.00,Bottom,,0,0,0,,\u4e0b\u306e\u30bb\u30ea\u30d5', + 'Dialogue: 0,0:00:01.00,0:00:03.00,TopSong,,0,0,0,,\u6b4c\u8a5e\u306e\u884c', + 'Dialogue: 0,0:00:01.00,0:00:03.00,Bottom,,0,0,0,,{\\an8}\u4e0a\u66f8\u304d\u306e\u884c', + 'Dialogue: 0,0:00:01.00,0:00:03.00,Bottom,,0,0,0,,{\\pos(640,20)}\u770b\u677f\u306e\u884c', + ].join('\n'); + + const bands = parseAssCues(ass).map((cue) => cue.assLayout?.verticalBand); + assert.deepEqual(bands, ['bottom', 'top', 'top', 'top']); +}); diff --git a/src/core/services/subtitle-cue-parser.ts b/src/core/services/subtitle-cue-parser.ts index ae5ea0d5..2a16ddd6 100644 --- a/src/core/services/subtitle-cue-parser.ts +++ b/src/core/services/subtitle-cue-parser.ts @@ -2,16 +2,49 @@ import { assOverrideSignature, assToPlainText, collectAssOverrideCommands, + hasAssTemporalOverride, parseAssEffectField, + removeAssControlDebrisLines, type AssEffectKind, type AssOverrideCommand, } from './ass-text'; -import { mergeDuplicateCues } from './subtitle-cue-dedup'; +import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup'; + +/** Vertical third of the screen a cue is authored to occupy. */ +export type AssVerticalBand = 'top' | 'middle' | 'bottom'; + +export type AssCueLayout = + | { + kind: 'positioned'; + sourceOrder: number; + x?: number; + y: number; + verticalBand?: AssVerticalBand; + } + | { kind: 'fragment-grid'; sourceOrder: number; verticalBand?: AssVerticalBand } + | { kind: 'source-order'; sourceOrder: number; verticalBand?: AssVerticalBand }; export interface SubtitleCue { startTime: number; endTime: number; text: string; + /** + * ASS ruby text removed from the published cue. Kept only so live `sub-text` matching + * can account for the extra lines mpv still reports from the source track. + */ + assFurigana?: readonly string[]; + /** How a complete line was recovered from generated ASS animation events. */ + source?: 'canonical-ass' | 'reconstructed-ass'; + /** + * Full span of the generated animation events a recovered cue replaced. Entrance and + * exit frames can run past canonical authored timing. + */ + animationStartTime?: number; + animationEndTime?: number; + /** ASS style retained only for fragment-reconstructed lines. */ + assStyle?: string; + /** Authored ASS ordering metadata used when flattening simultaneous positioned cues. */ + assLayout?: AssCueLayout; } /** @@ -19,7 +52,8 @@ export interface SubtitleCue { * Deduplication needs the authoring context -- which style the line belongs to, which * override commands it carries, whether the `Effect` column was set -- to tell a karaoke * burst apart from two characters saying the same word in turn. None of it is meaningful - * outside the parser, so the public API stays `{startTime, endTime, text}`. + * outside the parser, so the public API exposes only timing, text, and the optional + * recovery marker used by live subtitle consumers. */ export interface AnnotatedSubtitleCue extends SubtitleCue { /** Text exactly as authored, override blocks and all. */ @@ -65,12 +99,70 @@ function parseTimestamp( * line breaks, matching what mpv hands over for the same line played live. No layer * downstream decodes ASS again. */ +function decodeSubtitleCueText(text: string): string { + return assToPlainText(text, '\n').replace(HTML_SUBTITLE_TAG_PATTERN, ''); +} + function sanitizeSubtitleCueText(text: string): string { - return assToPlainText(text, '\n').replace(HTML_SUBTITLE_TAG_PATTERN, '').trim(); + return decodeSubtitleCueText(text).trim(); +} + +function sanitizeAssCueText(text: string): string { + return removeAssControlDebrisLines(decodeSubtitleCueText(text)).trim(); +} + +function attachAssMetadata( + cue: T, + assLayout: AssCueLayout | undefined, + assFurigana: readonly string[] | undefined, +): T { + if (assLayout) { + Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false }); + } + if (assFurigana?.length) { + Object.defineProperty(cue, 'assFurigana', { value: assFurigana, enumerable: false }); + } + return cue; } function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] { - return cues.map(({ startTime, endTime, text }) => ({ startTime, endTime, text })); + return cues.map( + ({ + startTime, + endTime, + text, + source, + animationStartTime, + animationEndTime, + style, + assLayout, + assFurigana, + }) => { + const common = { + startTime, + endTime, + text, + }; + if (source === 'reconstructed-ass') { + return attachAssMetadata( + { + ...common, + source, + animationStartTime, + animationEndTime, + assStyle: style, + }, + assLayout, + assFurigana, + ); + } + return attachAssMetadata( + source ? { ...common, source, animationStartTime, animationEndTime } : common, + assLayout, + assFurigana, + ); + }, + ); } function parseAnnotatedSrtCues(content: string): AnnotatedSubtitleCue[] { @@ -138,7 +230,18 @@ export function parseSrtCues(content: string): SubtitleCue[] { const ASS_TIMING_PATTERN = /^(\d+):(\d{2}):(\d{2})\.(\d{1,2})$/; const ASS_FORMAT_PREFIX = 'Format:'; const ASS_DIALOGUE_PREFIX = 'Dialogue:'; +const ASS_COMMENT_PREFIX = 'Comment:'; const ASS_NAME_FIELD_ALIASES = ['name', 'actor']; +const CANONICAL_MATCH_MARGIN_SECONDS = 1; +const MIN_CANONICAL_ANIMATION_EVENTS = 3; +// A tiny animated fragment can itself be composed from still smaller glyph events. It is +// not enough evidence that the fragment represents an authored line boundary. +const MIN_CANONICAL_DIALOGUE_TEXT_LENGTH = 4; +const MIN_FRAGMENT_LINE_EVENTS = 8; +const MIN_FRAGMENT_LINE_PARTS = 4; +const MAX_FRAGMENT_MEDIAN_LENGTH = 4; +const MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS = 2; +const MAX_FRAGMENT_LINE_VERTICAL_SPAN = 48; function parseAssTimestamp(raw: string): number | null { const match = ASS_TIMING_PATTERN.exec(raw.trim()); @@ -166,10 +269,2085 @@ function findFieldIndex(formatFields: string[], aliases: string[]): number { return -1; } -function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] { +interface ParsedAssEvents { + dialogue: AnnotatedSubtitleCue[]; + comments: AnnotatedSubtitleCue[]; +} + +// Every candidate line re-reads the compacted text of each event in its window, so on +// fragment-heavy scripts the same event compacts thousands of times without this cache. +const compactMatchTextCache = new WeakMap(); + +function compactAssMatchText(text: string): string { + return text.replace(/\s+/gu, ''); +} + +function compactCueMatchText(cue: AnnotatedSubtitleCue): string { + let compact = compactMatchTextCache.get(cue); + if (compact === undefined) { + compact = compactAssMatchText(cue.text); + compactMatchTextCache.set(cue, compact); + } + return compact; +} + +function assEventGroupKey(cue: AnnotatedSubtitleCue): string { + return `${cue.style}\0${cue.name}`; +} + +/** + * Windowed lookup over one style/name group. Every candidate line queries its time + * neighborhood, and fragment-heavy scripts put thousands of candidates in one group, so + * a linear rescan per candidate is quadratic in practice. Events are sorted by start + * once; `prefixMaxEnd` lets the backward walk stop as soon as no earlier event can still + * reach the window. + */ +interface AssEventGroupIndex { + byStart: AnnotatedSubtitleCue[]; + prefixMaxEnd: number[]; +} + +function buildAssEventGroupIndex(events: readonly AnnotatedSubtitleCue[]): AssEventGroupIndex { + const byStart = [...events].sort((a, b) => a.startTime - b.startTime || a.order - b.order); + const prefixMaxEnd: number[] = []; + let maxEnd = -Infinity; + for (const event of byStart) { + maxEnd = Math.max(maxEnd, event.endTime); + prefixMaxEnd.push(maxEnd); + } + return { byStart, prefixMaxEnd }; +} + +/** Group events overlapping `[startTime, endTime]`, returned in source order. */ +function eventsOverlappingWindow( + index: AssEventGroupIndex, + startTime: number, + endTime: number, +): AnnotatedSubtitleCue[] { + const { byStart, prefixMaxEnd } = index; + let low = 0; + let high = byStart.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (byStart[mid]!.startTime <= endTime) { + low = mid + 1; + } else { + high = mid; + } + } + const matches: AnnotatedSubtitleCue[] = []; + for (let i = low - 1; i >= 0 && prefixMaxEnd[i]! >= startTime; i -= 1) { + if (byStart[i]!.endTime >= startTime) { + matches.push(byStart[i]!); + } + } + return matches.sort((a, b) => a.order - b.order); +} + +interface FragmentGroup { + text: string; + events: AnnotatedSubtitleCue[]; +} + +// Anchor extraction walks every override command, and the canonical/fragment passes +// consult the same events once per candidate neighborhood, so heavy KFX tags make the +// uncached form quadratic in practice. +const placementAnchorCache = new WeakMap>(); + +function fragmentPlacementAnchors(event: AnnotatedSubtitleCue): Set { + let anchors = placementAnchorCache.get(event); + if (anchors) { + return anchors; + } + anchors = new Set(); + for (const command of event.overrides) { + const name = command.name.toLowerCase(); + const args = command.args.split(',').map((value) => value.trim()); + if (name === 'pos' && args.length >= 2) { + anchors.add(`pos:${args[0]},${args[1]}`); + } else if (name === 'move' && args.length >= 4) { + anchors.add(`move:${args[0]},${args[1]}`); + anchors.add(`move:${args[2]},${args[3]}`); + } + } + placementAnchorCache.set(event, anchors); + return anchors; +} + +// Drop-shadow layer copies sit a few pixels off their base glyph, while even tightly +// kerned repeated glyphs in one line ("ii") measure 10px apart or more. +const LAYER_COPY_OFFSET_TOLERANCE_PX = 6; + +// Copies chain only through genuine time overlap. Consecutive re-runs of one visual +// (chant bursts, countdown frames, jitter animation frames) abut or micro-overlap at +// frame seams, so the different-structure threshold sits above a frame seam while the +// phases of one effect (a highlight and the exit ghost it launches) overlap far longer. +const MIN_PHASE_OVERLAP_SECONDS = 0.04; + +// Decoration is timed to the line it accompanies, but a lead-in echo can end exactly +// where the recovered line's first sung copy begins; a small tolerance keeps such +// flush decoration attached to its line. +const DECORATION_SPAN_TOLERANCE_SECONDS = 0.1; + +// Guards for pathological event volumes. Real copy stacks and repaint chains stay in +// the hundreds; a same-text bucket or candidate sweep group in the thousands is a +// particle field, and the quadratic passes over it would stall the main process. +const MAX_COALESCE_BUCKET_EVENTS = 1500; +const MAX_SWEEP_GROUP_EVENTS = 4000; +// Fragment-layer collapsing repeatedly rescans the remaining parts after each match. +// Genuine authored lines stay far below this limit; larger groups are particle fields. +const MAX_FRAGMENT_COLLAPSE_PARTS = 1500; + +/** + * Sources behind a coalesced copy chain. Synthetic cues stand in for their sources + * during fragment recovery, but suppression and copy-count evidence must reach the + * original events, which are what the published dialogue list still holds. + */ +const coalescedSourceEvents = new WeakMap(); + +function sourceEventsOf(cue: AnnotatedSubtitleCue): readonly AnnotatedSubtitleCue[] { + return coalescedSourceEvents.get(cue) ?? [cue]; +} + +function sourceEventCount(events: readonly AnnotatedSubtitleCue[]): number { + return events.reduce((count, event) => count + sourceEventsOf(event).length, 0); +} + +// One representative point per placement command: the `\pos` point or the `\move` +// midpoint. Comparing raw `\move` endpoints cross-wise misreads a travel distance that +// matches the glyph advance as a layer copy of a neighboring same-letter glyph. +const anchorPointCache = new WeakMap(); + +function fragmentAnchorPoints(event: AnnotatedSubtitleCue): AssFragmentPosition[] { + let points = anchorPointCache.get(event); + if (points) { + return points; + } + points = []; + for (const command of event.overrides) { + const name = command.name.toLowerCase(); + const args = command.args.split(',').map((value) => Number(value.trim())); + if (name === 'pos' && args.length >= 2 && args.slice(0, 2).every(Number.isFinite)) { + points.push({ x: args[0]!, y: args[1]! }); + } else if (name === 'move' && args.length >= 4 && args.slice(0, 4).every(Number.isFinite)) { + points.push({ x: (args[0]! + args[2]!) / 2, y: (args[1]! + args[3]!) / 2 }); + } + } + anchorPointCache.set(event, points); + return points; +} + +function isRepeatedFragmentCopy( + previous: AnnotatedSubtitleCue, + current: AnnotatedSubtitleCue, +): boolean { + const previousAnchors = fragmentPlacementAnchors(previous); + if ([...fragmentPlacementAnchors(current)].some((anchor) => previousAnchors.has(anchor))) { + return true; + } + const previousPoints = fragmentAnchorPoints(previous); + const nearbyAnchor = fragmentAnchorPoints(current).some((point) => + previousPoints.some( + (previousPoint) => + Math.abs(point.x - previousPoint.x) <= LAYER_COPY_OFFSET_TOLERANCE_PX && + Math.abs(point.y - previousPoint.y) <= LAYER_COPY_OFFSET_TOLERANCE_PX, + ), + ); + if (nearbyAnchor) { + return true; + } + return ( + previous.startTime === current.startTime && + previous.endTime === current.endTime && + previous.overrideSignature === current.overrideSignature + ); +} + +// Coalescing keys on where a copy is anchored: the `\pos` point and both `\move` +// endpoints, since exit ghosts launch from the glyph anchor and entrance copies +// converge onto it. +function coalesceAnchorPoints(event: AnnotatedSubtitleCue): AssFragmentPosition[] { + const points: AssFragmentPosition[] = []; + for (const command of event.overrides) { + const name = command.name.toLowerCase(); + const args = command.args.split(',').map((value) => Number(value.trim())); + if (name === 'pos' && args.length >= 2 && args.slice(0, 2).every(Number.isFinite)) { + points.push({ x: args[0]!, y: args[1]! }); + } else if (name === 'move' && args.length >= 4 && args.slice(0, 4).every(Number.isFinite)) { + points.push({ x: args[0]!, y: args[1]! }); + points.push({ x: args[2]!, y: args[3]! }); + } + } + return points; +} + +function shareCoalesceAnchor( + left: readonly AssFragmentPosition[], + right: readonly AssFragmentPosition[], +): boolean { + return right.some((point) => + left.some( + (other) => + Math.abs(point.x - other.x) <= LAYER_COPY_OFFSET_TOLERANCE_PX && + Math.abs(point.y - other.y) <= LAYER_COPY_OFFSET_TOLERANCE_PX, + ), + ); +} + +// The set of distinct command names, ignoring arguments and repetition. Two phases of +// one effect (pre-echo, highlight, hold) carry different command vocabularies; a re-run +// of the same visual (chant burst, countdown frame) repeats the same vocabulary with new +// argument values -- including a different number of animation keyframes, which is why +// repetition must not count. +const structuralSignatureCache = new WeakMap(); + +function structuralOverrideSignature(cue: AnnotatedSubtitleCue): string { + let signature = structuralSignatureCache.get(cue); + if (signature === undefined) { + const names = new Set( + cue.overrides.map((command) => `${command.animated ? '~' : ''}${command.name.toLowerCase()}`), + ); + signature = [...names].sort().join(','); + structuralSignatureCache.set(cue, signature); + } + return signature; +} + +// A transparent lead-in ends exactly where its glyph's first sung copy begins, so the +// echo-to-visible handoff must chain across a small seam. +const COALESCE_SEAM_TOLERANCE_SECONDS = 0.05; + +/** + * Two same-text copies chain when their windows genuinely overlap. Structurally + * identical events are layers of one visual exactly when their windows substantially + * coincide; a frame seam or few-millisecond overlap between identical structures is a + * re-run (the next chant burst, the next countdown frame). Structurally different + * events are phases of one effect and chain across any above-seam overlap. A bare seam + * only joins a transparent echo to its visible phase: the lead-in before a highlight + * belongs to its glyph, while two visible events that merely abut (the next burst of a + * chant, an exit flash after a hold) are separate showings. + */ +function areTimeConnectedCopies(a: AnnotatedSubtitleCue, b: AnnotatedSubtitleCue): boolean { + const overlap = Math.min(a.endTime, b.endTime) - Math.max(a.startTime, b.startTime); + if (structuralOverrideSignature(a) === structuralOverrideSignature(b)) { + const shorterDuration = Math.min(a.endTime - a.startTime, b.endTime - b.startTime); + return overlap >= Math.max(MIN_PHASE_OVERLAP_SECONDS, shorterDuration / 2); + } + if (overlap >= MIN_PHASE_OVERLAP_SECONDS) { + return true; + } + return ( + overlap >= -COALESCE_SEAM_TOLERANCE_SECONDS && + isTransparentFillEcho(a) !== isTransparentFillEcho(b) + ); +} + +function buildCoalescedCopy(members: readonly AnnotatedSubtitleCue[]): AnnotatedSubtitleCue { + const ordered = [...members].sort((left, right) => left.order - right.order); + const representative = + ordered.find((member) => + member.overrides.some((command) => !command.animated && command.name.toLowerCase() === 'pos'), + ) ?? ordered[0]!; + const synthetic: AnnotatedSubtitleCue = { + ...representative, + startTime: earliestStartTime(ordered), + endTime: latestEndTime(ordered), + order: ordered[0]!.order, + }; + coalescedSourceEvents.set(synthetic, ordered); + return synthetic; +} + +/** + * Generated glyph effects render one authored glyph as a stack of copies anchored at the + * same point: a transparent pre-echo until the syllable is sung, a short highlight, an + * exit ghost launched from the anchor, and a steady hold to the end of the line. Their + * windows abut rather than coincide, so per-event timing says four unrelated fragments + * while the anchor says one glyph. Merging each stack into a single presence spanning + * the union window lets timing clusters see the authored line instead of its phases. + */ +function coalesceAssAnchorCopies(events: readonly AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] { + const buckets = new Map(); + const anchorPoints: (AssFragmentPosition[] | null)[] = events.map(() => null); + events.forEach((event, index) => { + const text = compactCueMatchText(event); + if (!text) { + return; + } + const points = coalesceAnchorPoints(event); + if (points.length === 0) { + return; + } + anchorPoints[index] = points; + const bucket = buckets.get(text); + if (bucket) { + bucket.push(index); + } else { + buckets.set(text, [index]); + } + }); + for (const [text, bucket] of buckets) { + if (bucket.length > MAX_COALESCE_BUCKET_EVENTS) { + // Pathological same-text volume (a whole-episode particle field). Pairing would + // stall the main process; uncoalesced events fall back to the burst/grid paths. + buckets.delete(text); + } else { + bucket.sort((left, right) => events[left]!.startTime - events[right]!.startTime); + } + } + + const parent = events.map((_, index) => index); + const findRoot = (index: number): number => { + let root = index; + while (parent[root] !== root) { + root = parent[root]!; + } + while (parent[index] !== root) { + const next = parent[index]!; + parent[index] = root; + index = next; + } + return root; + }; + const union = (left: number, right: number): void => { + parent[findRoot(left)] = findRoot(right); + }; + + for (const bucket of buckets.values()) { + // Buckets are start-sorted; copies can only connect through time proximity, so the + // backward scan stops once no earlier copy's window can still reach this one. + const prefixMaxEnd: number[] = []; + let maxEnd = -Infinity; + for (const index of bucket) { + maxEnd = Math.max(maxEnd, events[index]!.endTime); + prefixMaxEnd.push(maxEnd); + } + for (let i = 1; i < bucket.length; i += 1) { + const right = bucket[i]!; + const reachableStart = events[right]!.startTime - COALESCE_SEAM_TOLERANCE_SECONDS; + for (let j = i - 1; j >= 0 && prefixMaxEnd[j]! >= reachableStart; j -= 1) { + const left = bucket[j]!; + if ( + shareCoalesceAnchor(anchorPoints[left]!, anchorPoints[right]!) && + areTimeConnectedCopies(events[left]!, events[right]!) + ) { + union(left, right); + } + } + } + } + + const componentsByRoot = new Map(); + events.forEach((event, index) => { + const root = findRoot(index); + const members = componentsByRoot.get(root); + if (members) { + members.push(event); + } else { + componentsByRoot.set(root, [event]); + } + }); + if (componentsByRoot.size === events.length) { + return [...events]; + } + return [...componentsByRoot.values()] + .map((members) => (members.length === 1 ? members[0]! : buildCoalescedCopy(members))) + .sort((left, right) => left.order - right.order); +} + +function hasRelaxedAssFragmentEvidence(events: readonly AnnotatedSubtitleCue[]): boolean { + if ( + sourceEventCount(events) < 2 || + !events.every((event) => fragmentPlacementAnchors(event).size > 0) + ) { + return false; + } + + const latestStart = events.reduce( + (latest, event) => Math.max(latest, event.startTime), + -Infinity, + ); + const earliestEnd = events.reduce( + (earliest, event) => Math.min(earliest, event.endTime), + Infinity, + ); + if (latestStart >= earliestEnd) { + return false; + } + + const first = events[0]!; + const hasChangingOverrides = events.some( + (event) => event.overrideSignature !== first.overrideSignature, + ); + const hasPositionedLayerCopy = + events.some((event) => sourceEventsOf(event).length > 1) || + events.some((event, index) => + events + .slice(0, index) + .some( + (previous) => + compactCueMatchText(previous) === compactCueMatchText(event) && + isRepeatedFragmentCopy(previous, event), + ), + ); + return hasChangingOverrides || hasPositionedLayerCopy; +} + +interface AssFragmentPart { + cue: AnnotatedSubtitleCue; + text: string; +} + +interface AssFragmentPosition { + x: number; + y: number; +} + +const MIN_LATIN_POSITION_GAP_SAMPLES = 4; +const LATIN_FRAGMENT_WORD_GAP_RATIO = 1.16; +const LATIN_GLYPH_WORD_GAP_RATIO = 1.4; +// Word-space advance beyond the width-predicted glyph advance, as a fraction of the +// line's common unit. Measured corpus extremes: widest within-word excess 0.32 (`pp` +// with tracking), narrowest word gap 0.40 (`s w` across a wide glyph). That margin only +// holds when the common unit is estimated from enough glyph pairs; a short single-word +// line (`Swelling`) skews the unit low and its ordinary advances read as word gaps. +const LATIN_GLYPH_WORD_EXCESS_RATIO = 0.36; +const MIN_LATIN_GLYPH_EXCESS_GAP_SAMPLES = 10; +// Multi-character syllable chunks average out proportional-font variation, so their +// advances track the width model far more closely than single glyphs do. Measured on a +// chunked lyric line, within-word excess stayed under 0.07 of the common unit while every +// word gap cleared 0.31, so a tighter margin separates them without splitting words. +const LATIN_CHUNK_WORD_EXCESS_RATIO = 0.2; +const MIN_LATIN_CHUNK_EXCESS_GAP_SAMPLES = 6; +const LATIN_TWO_GLYPH_WORD_NEXT_GAP_RATIO = 1.2; + +function fragmentPosition(cue: AnnotatedSubtitleCue): AssFragmentPosition | null { + for (const command of cue.overrides) { + if (command.animated) continue; + const name = command.name.toLowerCase(); + const args = command.args.split(',').map((value) => Number(value.trim())); + if ( + name === 'pos' && + args.length >= 2 && + Number.isFinite(args[0]) && + Number.isFinite(args[1]) + ) { + return { x: args[0]!, y: args[1]! }; + } + if ( + name === 'move' && + args.length >= 4 && + args.slice(0, 4).every((value) => Number.isFinite(value)) + ) { + return { x: (args[0]! + args[2]!) / 2, y: (args[1]! + args[3]!) / 2 }; + } + } + return null; +} + +function latinGlyphWidthWeight(glyph: string): number { + if (/[ilIj]/u.test(glyph)) return 0.6; + if (/[tfr]/u.test(glyph)) return 0.8; + if (/[mwMW]/u.test(glyph)) return 1.4; + if (/[A-Z]/u.test(glyph)) return 1.1; + return 1; +} + +function latinFragmentWidthWeight(text: string): number | null { + if (!/^[A-Za-z0-9'’.,!?;:-]+$/u.test(text)) return null; + const punctuationWeight = /^[A-Za-z0-9]['’.,!?;:-]$/u.test(text) ? 0.5 : 0.25; + return [...text].reduce( + (width, glyph) => + width + (/['’.,!?;:-]/u.test(glyph) ? punctuationWeight : latinGlyphWidthWeight(glyph)), + 0, + ); +} + +function isSingleLatinGlyphFragment(text: string): boolean { + return [...text].filter((glyph) => /[A-Za-z0-9]/u.test(glyph)).length <= 1; +} + +interface LatinFragmentGapMeasure { + distance: number; + meanWeight: number; +} + +function latinFragmentGapMeasure( + previous: AssFragmentPart, + current: AssFragmentPart, +): LatinFragmentGapMeasure | null { + const previousWeight = latinFragmentWidthWeight(previous.text); + const currentWeight = latinFragmentWidthWeight(current.text); + const previousPosition = fragmentPosition(previous.cue); + const currentPosition = fragmentPosition(current.cue); + if (previousWeight === null || currentWeight === null || !previousPosition || !currentPosition) { + return null; + } + const xDistance = currentPosition.x - previousPosition.x; + const yDistance = Math.abs(currentPosition.y - previousPosition.y); + if (yDistance <= 2 && xDistance <= 0) return null; + + // A wrapped authored line can return to the left on its next visual row. Preserve + // that measured row transition as a separator without treating backwards movement + // on the same row as a word gap. + const distance = yDistance <= 2 ? xDistance : Math.abs(xDistance) + yDistance; + return { distance, meanWeight: (previousWeight + currentWeight) / 2 }; +} + +function normalizedLatinFragmentGap( + previous: AssFragmentPart, + current: AssFragmentPart, +): number | null { + const measure = latinFragmentGapMeasure(previous, current); + return measure === null ? null : measure.distance / measure.meanWeight; +} + +function startsNewPositionedFragmentSequence( + previous: AssFragmentPart, + current: AssFragmentPart, +): boolean { + const previousPosition = fragmentPosition(previous.cue); + const currentPosition = fragmentPosition(current.cue); + return Boolean( + previousPosition && + currentPosition && + Math.abs(currentPosition.y - previousPosition.y) <= 2 && + currentPosition.x <= previousPosition.x && + current.cue.startTime > previous.cue.startTime, + ); +} + +function commonLatinFragmentGap(values: readonly number[]): number { + const sorted = [...values].sort((left, right) => left - right); + // Romaji lines contain many short particles, so real word gaps can outnumber + // within-word transitions. A lower quantile still represents ordinary glyph advance + // while ignoring the narrowest character pair as an outlier. + return sorted[Math.floor((sorted.length - 1) * 0.35)]!; +} + +function isLikelyTwoGlyphCapitalizedWord(options: { + parts: readonly AssFragmentPart[]; + index: number; + gap: number; + wordGapThreshold: number; +}): boolean { + const first = options.parts[options.index - 1]!; + const second = options.parts[options.index]!; + if (!/^[A-Z]$/u.test(first.text) || !/^[a-z]$/u.test(second.text)) { + return false; + } + + const precedingGap = + options.index > 1 ? normalizedLatinFragmentGap(options.parts[options.index - 2]!, first) : null; + const following = options.parts[options.index + 1]; + const followingGap = following ? normalizedLatinFragmentGap(second, following) : null; + const startsAtWordBoundary = + options.index === 1 || (precedingGap !== null && precedingGap > options.wordGapThreshold); + + return ( + startsAtWordBoundary && + followingGap !== null && + followingGap > options.wordGapThreshold && + followingGap > options.gap * LATIN_TWO_GLYPH_WORD_NEXT_GAP_RATIO + ); +} + +/** + * Character-by-character typesetting often omits literal spaces because the authored + * word gap exists only in each glyph's `\pos`. Estimate the normal adjacent-glyph + * advance within that one line, then preserve only materially larger horizontal gaps. + * Normalizing each gap by the neighboring fragment widths supports both single glyphs + * and multi-character karaoke syllables without guessing from the text itself. Per-glyph + * runs use a wider safety margin because proportional fonts vary more than syllable chunks. + * + * The ratio test alone under-detects a word gap next to a wide fragment (`waves within` + * measured across `s`/`w`, or `Choices presumably` across two three-letter chunks, both + * normalize to nearly a common advance), so a gap also counts as a word boundary when its + * advance exceeds the width-predicted advance by a material fraction of the line's common + * unit -- a word space adds a roughly constant extra distance no matter how wide its + * neighbors are. Chunk runs use a tighter margin than per-glyph runs because their + * advances deviate less from the width model. + * + * A line may mix both conventions: one fragment carrying a literal space while its + * neighbors rely on position alone. Whitespace-bearing fragments have no width weight, so + * they drop out of the estimate and their own boundary comes from the authored space, + * leaving the surrounding positional gaps to be recovered normally. + */ +function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string { + const normalizedGaps: number[] = []; + for (let index = 1; index < parts.length; index += 1) { + const gap = normalizedLatinFragmentGap(parts[index - 1]!, parts[index]!); + if (gap !== null) normalizedGaps.push(gap); + } + const isGlyphRun = parts.every((part) => isSingleLatinGlyphFragment(part.text)); + const commonGap = + normalizedGaps.length >= MIN_LATIN_POSITION_GAP_SAMPLES + ? commonLatinFragmentGap(normalizedGaps) + : null; + const wordGapThreshold = + commonGap === null + ? Infinity + : commonGap * (isGlyphRun ? LATIN_GLYPH_WORD_GAP_RATIO : LATIN_FRAGMENT_WORD_GAP_RATIO); + + let text = parts[0]?.text ?? ''; + for (let index = 1; index < parts.length; index += 1) { + const previous = parts[index - 1]!; + const current = parts[index]!; + const hasAuthoredSpace = /\s$/u.test(previous.text) || /^\s/u.test(current.text); + const measure = latinFragmentGapMeasure(previous, current); + const normalizedGap = measure === null ? null : measure.distance / measure.meanWeight; + // A capital into lowercase is almost always a capitalized word's own first letters + // (`S|miles`), and capitals overrun the width table too easily, so the excess rule + // never fires there. A lone capital word like `I` is narrow enough for the ratio + // test to catch its word gap on its own. + const excessRatio = isGlyphRun ? LATIN_GLYPH_WORD_EXCESS_RATIO : LATIN_CHUNK_WORD_EXCESS_RATIO; + const minimumExcessSamples = isGlyphRun + ? MIN_LATIN_GLYPH_EXCESS_GAP_SAMPLES + : MIN_LATIN_CHUNK_EXCESS_GAP_SAMPLES; + const hasAdvanceExcess = + commonGap !== null && + normalizedGaps.length >= minimumExcessSamples && + measure !== null && + !(/^[A-Z]$/u.test(previous.text) && /^[a-z]$/u.test(current.text)) && + measure.distance - measure.meanWeight * commonGap > excessRatio * commonGap; + const hasPositionedWordGap = + startsNewPositionedFragmentSequence(previous, current) || + (normalizedGap !== null && + (normalizedGap > wordGapThreshold || hasAdvanceExcess) && + !isLikelyTwoGlyphCapitalizedWord({ + parts, + index, + gap: normalizedGap, + wordGapThreshold, + })); + if (!hasAuthoredSpace && hasPositionedWordGap) { + text += ' '; + } + text += current.text; + } + return text.trim(); +} + +// A tall multi-part layout is only a visual grid when its parts read like tiling +// rather than prose: a couple of texts repeated across many fragments (sign walls), +// the same text re-shown at the same spot over time (countdown/animation frames), +// nothing but scattered single glyphs, or cells aligned into table columns. Wrapped +// lyric rows with repeated karaoke syllables and CC-style dialogue blocks (speaker +// labels plus a sentence) share the same tall geometry but stay publishable. +function looksLikeFragmentGridParts(parts: readonly AssFragmentPart[]): boolean { + const positioned = parts + .map((part) => ({ + text: part.text.trim(), + layout: part.cue.assLayout, + position: fragmentPosition(part.cue), + startTime: part.cue.startTime, + })) + .filter((part) => part.text && part.layout?.kind === 'positioned'); + if (positioned.length === 0) return true; + + const uniqueTexts = new Set(positioned.map((part) => part.text)); + if (uniqueTexts.size * 3 <= positioned.length) return true; + + if (positioned.every((part) => [...part.text].length <= 1)) return true; + + const seenPlacements = new Map(); + for (const part of positioned) { + if (part.layout?.kind !== 'positioned' || !part.position) continue; + const placement = `${part.text}@${Math.round(part.position.x)},${Math.round(part.position.y)}`; + const earlierStart = seenPlacements.get(placement); + if (earlierStart !== undefined && Math.abs(part.startTime - earlierStart) > 0.01) { + return true; + } + seenPlacements.set(placement, part.startTime); + } + + // Table cells align into columns: several x values each reused on multiple rows. + // Requiring two such columns holding at least half the parts keeps a wrapped lyric + // whose rows accidentally share one x coordinate out of the grid bucket. + const columnRows = new Map>(); + for (const part of positioned) { + if (!part.position) continue; + const x = Math.round(part.position.x); + const rows = columnRows.get(x) ?? new Set(); + rows.add(Math.round(part.position.y)); + columnRows.set(x, rows); + } + let alignedColumns = 0; + let alignedParts = 0; + for (const part of positioned) { + if (!part.position) continue; + if ((columnRows.get(Math.round(part.position.x))?.size ?? 0) >= 2) alignedParts += 1; + } + for (const rows of columnRows.values()) { + if (rows.size >= 2) alignedColumns += 1; + } + return alignedColumns >= 2 && alignedParts * 2 >= positioned.length; +} + +function reconstructedAssFragmentLayout( + parts: readonly AssFragmentPart[], + owner: AnnotatedSubtitleCue, +): AssCueLayout | undefined { + let positionedPartCount = 0; + let minimumY = Infinity; + let maximumY = -Infinity; + for (const part of parts) { + const layout = part.cue.assLayout; + if (layout?.kind !== 'positioned') continue; + positionedPartCount += 1; + minimumY = Math.min(minimumY, layout.y); + maximumY = Math.max(maximumY, layout.y); + } + + if ( + positionedPartCount >= MIN_FRAGMENT_LINE_PARTS && + maximumY - minimumY > MAX_FRAGMENT_LINE_VERTICAL_SPAN && + looksLikeFragmentGridParts(parts) + ) { + return { kind: 'fragment-grid', sourceOrder: owner.order }; + } + return owner.assLayout; +} + +interface AssFragmentTimingCluster { + events: AnnotatedSubtitleCue[]; + minStartTime: number; + maxStartTime: number; + minEndTime: number; + maxEndTime: number; +} + +function addToFragmentTimingCluster( + cluster: AssFragmentTimingCluster, + cue: AnnotatedSubtitleCue, +): void { + cluster.events.push(cue); + cluster.minStartTime = Math.min(cluster.minStartTime, cue.startTime); + cluster.maxStartTime = Math.max(cluster.maxStartTime, cue.startTime); + cluster.minEndTime = Math.min(cluster.minEndTime, cue.endTime); + cluster.maxEndTime = Math.max(cluster.maxEndTime, cue.endTime); +} + +function fragmentTimingDistance( + cluster: AssFragmentTimingCluster, + cue: AnnotatedSubtitleCue, +): number { + const nextMinStart = Math.min(cluster.minStartTime, cue.startTime); + const nextMaxStart = Math.max(cluster.maxStartTime, cue.startTime); + const nextMinEnd = Math.min(cluster.minEndTime, cue.endTime); + const nextMaxEnd = Math.max(cluster.maxEndTime, cue.endTime); + if ( + nextMaxStart - nextMinStart > MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS || + nextMaxEnd - nextMinEnd > MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS + ) { + return Infinity; + } + return ( + Math.abs(cue.startTime - (cluster.minStartTime + cluster.maxStartTime) / 2) + + Math.abs(cue.endTime - (cluster.minEndTime + cluster.maxEndTime) / 2) + ); +} + +function clusterAssFragmentEvents( + events: readonly AnnotatedSubtitleCue[], +): AssFragmentTimingCluster[] { + const clusters: AssFragmentTimingCluster[] = []; + for (const cue of events) { + let nearest: AssFragmentTimingCluster | null = null; + let nearestDistance = Infinity; + for (const cluster of clusters) { + const distance = fragmentTimingDistance(cluster, cue); + if (distance < nearestDistance) { + nearest = cluster; + nearestDistance = distance; + } + } + if (nearest) { + addToFragmentTimingCluster(nearest, cue); + } else { + clusters.push({ + events: [cue], + minStartTime: cue.startTime, + maxStartTime: cue.startTime, + minEndTime: cue.endTime, + maxEndTime: cue.endTime, + }); + } + } + return clusters; +} + +interface FragmentInterval { + startTime: number; + endTime: number; +} + +/** Event time ranges with repeated same-text, same-time layer copies collapsed. */ +function distinctFragmentIntervals(events: readonly AnnotatedSubtitleCue[]): FragmentInterval[] { + const intervals: FragmentInterval[] = []; + const previousEvents: AnnotatedSubtitleCue[] = []; + for (const event of events) { + const compactText = compactCueMatchText(event); + const isLayerCopy = previousEvents.some( + (previous) => + compactCueMatchText(previous) === compactText && + previous.startTime === event.startTime && + previous.endTime === event.endTime && + isRepeatedFragmentCopy(previous, event), + ); + previousEvents.push(event); + if (isLayerCopy) continue; + intervals.push({ startTime: event.startTime, endTime: event.endTime }); + } + return intervals.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime); +} + +function intervalsNeverCoexist(intervals: readonly FragmentInterval[]): boolean { + let latestEnd = -Infinity; + for (const interval of intervals) { + if (interval.startTime < latestEnd - 0.001) { + return false; + } + latestEnd = Math.max(latestEnd, interval.endTime); + } + return true; +} + +// A sweep progresses through the syllables of a lyric, so its events carry different +// texts. Sequential same-text repaints are one shaking line being redrawn, and its text +// must survive to the raw/burst path rather than be suppressed as decoration. +function hasMultipleFragmentTexts(events: readonly AnnotatedSubtitleCue[]): boolean { + const first = events[0] ? compactCueMatchText(events[0]) : ''; + return events.some((event) => compactCueMatchText(event) !== first); +} + +/** + * A karaoke highlight sweep repaints one syllable at a time over an already-visible + * lyric line: each event ends as the next begins, so the cluster's concatenated text is + * never on screen as a whole. Publishing it would emit rolling partial copies of the + * lyric ("to sou omo" beside "akenakute ii to sou omotteta"). Layer copies share one + * placement and timing, so the test is whether any two distinct placements coexist. + */ +function isProgressiveHighlightSweep(events: readonly AnnotatedSubtitleCue[]): boolean { + if (!hasMultipleFragmentTexts(events)) { + return false; + } + const intervals = distinctFragmentIntervals(events); + return intervals.length >= 2 && intervalsNeverCoexist(intervals); +} + +/** + * Timing clusters split a long sweep unevenly, leaving stragglers the per-cluster check + * cannot judge: a two-event tail reconstructs on relaxed evidence, and a lone held + * syllable stays raw and publishes as its own flickering cue. When an entire style group + * reads as one chained repaint -- many short positioned animated fragments, no two ever + * on screen together, transitions mostly back-to-back -- the whole group is highlight + * decoration and none of it is publishable text. Independent one-off signs sharing a + * style stay published: they are few, longer, or separated by real gaps. + */ +function isProgressiveHighlightSweepGroup(events: readonly AnnotatedSubtitleCue[]): boolean { + if ( + events.length > MAX_SWEEP_GROUP_EVENTS || + sourceEventCount(events) < MIN_FRAGMENT_LINE_EVENTS || + !hasMultipleFragmentTexts(events) || + !events.every((event) => fragmentPlacementAnchors(event).size > 0) || + !hasAssAnimationEvidence(events) + ) { + return false; + } + const lengths = events + .map((event) => compactCueMatchText(event).length) + .sort((left, right) => left - right); + if ((lengths[Math.floor(lengths.length / 2)] ?? Infinity) > MAX_FRAGMENT_MEDIAN_LENGTH) { + return false; + } + const intervals = distinctFragmentIntervals(events); + if (intervals.length < 2 || !intervalsNeverCoexist(intervals)) { + return false; + } + let abutting = 0; + for (let index = 1; index < intervals.length; index += 1) { + if (Math.abs(intervals[index]!.startTime - intervals[index - 1]!.endTime) <= 0.1) { + abutting += 1; + } + } + return abutting * 2 >= intervals.length - 1; +} + +function decodeSingleAssFragment(cue: AnnotatedSubtitleCue): string | null { + const visibleLines = decodeSubtitleCueText(cue.rawText) + .split('\n') + .filter((line) => line.trim().length > 0); + return visibleLines.length === 1 ? visibleLines[0]! : null; +} + +/** + * One cluster can hold the same authored text at two granularities: a whole-line event + * and the per-glyph events that spell it (an assembly effect renders the line while its + * glyph particles converge). Joining both doubles the line. A consecutive run of two or + * more parts that concatenates to exactly another part's text is that part's fragment + * layer; the whole part keeps the authored spacing, so the run is dropped. Single equal + * parts are never dropped -- a repeated word in a lyric is real text, not a layer. + * + * Spelling alone is not proof: repeated digits after a thousands group also concatenate + * to the earlier fragment's text while being real continuation. A duplicate layer sits + * on top of its fragments, so the whole part's anchor must fall inside the run's + * positional span; text that merely continues the line sits beyond it. + */ +function isWholePartOverItsRun(whole: AssFragmentPart, run: readonly AssFragmentPart[]): boolean { + const wholePosition = fragmentPosition(whole.cue); + if (!wholePosition) { + return true; + } + const xs = run + .map((part) => fragmentPosition(part.cue)?.x) + .filter((x): x is number => Number.isFinite(x)); + if (xs.length === 0) { + return true; + } + return wholePosition.x >= Math.min(...xs) && wholePosition.x <= Math.max(...xs); +} + +function dropFragmentRunsCoveredByWholeParts(parts: AssFragmentPart[]): AssFragmentPart[] { + if (parts.length > MAX_FRAGMENT_COLLAPSE_PARTS) { + return parts; + } + const kept = [...parts]; + let changed = true; + while (changed) { + changed = false; + const wholes = [...kept].sort( + (left, right) => + compactAssMatchText(right.text).length - compactAssMatchText(left.text).length, + ); + for (const whole of wholes) { + const wholeText = compactAssMatchText(whole.text); + if ([...wholeText].length < 2) { + break; + } + for (let start = 0; start < kept.length && !changed; start += 1) { + if (kept[start] === whole) { + continue; + } + let combined = ''; + for (let end = start; end < kept.length; end += 1) { + if (kept[end] === whole) { + break; + } + combined += compactAssMatchText(kept[end]!.text); + if (!wholeText.startsWith(combined)) { + break; + } + if (combined === wholeText) { + const run = kept.slice(start, end + 1); + if (end > start && isWholePartOverItsRun(whole, run)) { + kept.splice(start, end - start + 1); + changed = true; + } + break; + } + } + } + if (changed) { + break; + } + } + } + return kept; +} + +function reconstructAssFragmentLine( + events: readonly AnnotatedSubtitleCue[], +): AnnotatedSubtitleCue | null { + const hasRelaxedEvidence = hasRelaxedAssFragmentEvidence(events); + const minimumEvents = hasRelaxedEvidence ? 2 : MIN_FRAGMENT_LINE_EVENTS; + // Coalesced copies stand in for their source events, so animation-volume thresholds + // count sources: a merged four-phase glyph is still four generated events of evidence. + if (sourceEventCount(events) < minimumEvents || !hasAssAnimationEvidence(events)) { + return null; + } + + const collected: AssFragmentPart[] = []; + for (const cue of events) { + const text = decodeSingleAssFragment(cue); + if (text === null) { + return null; + } + const compactText = compactAssMatchText(text); + const isLayerCopy = collected.some( + (part) => + compactAssMatchText(part.text) === compactText && isRepeatedFragmentCopy(part.cue, cue), + ); + if (!isLayerCopy) { + collected.push({ cue, text }); + } + } + const minimumParts = hasRelaxedEvidence ? 1 : MIN_FRAGMENT_LINE_PARTS; + if ( + collected.length < minimumParts || + (!hasRelaxedEvidence && collected.length === sourceEventCount(events)) + ) { + return null; + } + // A cluster whose every part is one identical glyph is a particle field, not a line. + // Reconstructing it would also break the surviving particles' same-text chain that + // burst deduplication collapses downstream. + if (collected.length >= 2) { + const firstText = compactAssMatchText(collected[0]!.text); + if ( + [...firstText].length === 1 && + collected.every((part) => compactAssMatchText(part.text) === firstText) + ) { + return null; + } + } + // The granularity gate judges the cluster as authored, so it runs before redundant + // whole-vs-fragments layers collapse: a line plus its glyph swarm is fragment-sized + // work even though only the whole-line part survives into the join. + const lengths = collected + .map((part) => compactAssMatchText(part.text).length) + .sort((left, right) => left - right); + if ((lengths[Math.floor(lengths.length / 2)] ?? Infinity) > MAX_FRAGMENT_MEDIAN_LENGTH) { + return null; + } + const parts = dropFragmentRunsCoveredByWholeParts(collected); + + const text = joinAssFragmentParts(parts); + if (!text) { + return null; + } + const owner = parts[0]!.cue; + const animationStartTime = earliestStartTime(events); + const animationEndTime = latestEndTime(events); + // Publish the window where the line reads as sung text. Transparent pre-echoes render + // the upcoming line before its first syllable, and exit ghosts fade past the hold, so + // the raw span makes consecutive lyrics overlap on screen. The line starts with its + // first opaque copy and holds until the last statically anchored one ends; a line + // placed entirely by `\move` has no hold phase and keeps its full visible span. + const sources = events.flatMap((event) => [...sourceEventsOf(event)]); + const visibleSources = sources.filter((source) => !isTransparentFillEcho(source)); + const displaySources = visibleSources.length > 0 ? visibleSources : sources; + const heldSources = displaySources.filter((source) => + source.overrides.some((command) => !command.animated && command.name.toLowerCase() === 'pos'), + ); + return { + ...owner, + startTime: earliestStartTime(displaySources), + endTime: latestEndTime(heldSources.length > 0 ? heldSources : displaySources), + text, + rawText: text, + source: 'reconstructed-ass', + animationStartTime, + animationEndTime, + assLayout: reconstructedAssFragmentLayout(parts, owner), + overrides: [], + overrideSignature: '', + }; +} + +// `\fnSplit splat splodge` tokenizes as name `fnSplit` + args `splat splodge`, while +// `\fnArial` is all name and `\fn04b` is all args, so the font is both pieces rejoined. +function staticFontOverride(cue: AnnotatedSubtitleCue): string | null { + let font: string | null = null; + for (const command of cue.overrides) { + if (command.animated || !command.name.toLowerCase().startsWith('fn')) continue; + font = [command.name.slice(2), command.args].filter(Boolean).join(' ').trim().toLowerCase(); + } + return font; +} + +const MIN_TEXTURE_GLYPH_RUN = 8; +const MIN_TEXTURE_ALPHA_OVERRIDES = 6; +// Texture payloads switch secondary alpha at nearly every glyph. Authored text that a +// typesetter styles in syllable or word chunks measures two or more glyphs per +// override, so the seed test demands per-glyph density. +const MAX_TEXTURE_GLYPHS_PER_ALPHA_OVERRIDE = 1.5; +const MIN_TEXTURE_LAYER_ALPHA = 0xe0; +const MAX_TEXTURE_PAYLOAD_FONT_SIZE = 12; +const MIN_TEXTURE_PAYLOAD_LINES = 3; +const ASS_ALPHA_VALUE_PATTERN = /^&?H([0-9a-f]{1,2})&?$/iu; +const ASS_FONT_WEIGHT_SUFFIX_PATTERN = + /\s+(?:black|bold|heavy|light|medium|regular|semibold|thin)$/u; + +function hasStaticOverride(cue: AnnotatedSubtitleCue, expectedName: string): boolean { + return cue.overrides.some( + (command) => !command.animated && command.name.toLowerCase() === expectedName, + ); +} + +function isRepeatedGlyphText(cue: AnnotatedSubtitleCue): boolean { + const glyphs = [...compactCueMatchText(cue)]; + return glyphs.length > 0 && glyphs.every((glyph) => glyph === glyphs[0]); +} + +function isClippedRepeatedGlyphFragment(cue: AnnotatedSubtitleCue): boolean { + return ( + isRepeatedGlyphText(cue) && (hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip')) + ); +} + +/** + * Some ASS signs build image textures from clipped placeholder glyphs, optionally through + * a texture font. A long clipped single-glyph run or frequent changing secondary alpha + * tags identifies the effect without guessing from its visible text or font name. + */ +function isAssTextureSeed(cue: AnnotatedSubtitleCue): boolean { + if (fragmentPosition(cue) === null) { + return false; + } + + // A truncated capture can leave tag debris after the placeholder run, so the seed + // test looks for a long same-glyph run inside the clipped text rather than requiring + // the whole event to be uniform. + const glyphs = [...compactCueMatchText(cue)]; + if (hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip')) { + let longestRun = 0; + let run = 0; + let previous = ''; + for (const glyph of glyphs) { + run = glyph === previous ? run + 1 : 1; + previous = glyph; + longestRun = Math.max(longestRun, run); + } + if (longestRun >= MIN_TEXTURE_GLYPH_RUN) { + return true; + } + } + + if (staticFontOverride(cue) === null) { + return false; + } + + const secondaryAlpha = cue.overrides.filter( + (command) => !command.animated && command.name.toLowerCase() === '2a', + ); + if (secondaryAlpha.length < MIN_TEXTURE_ALPHA_OVERRIDES) { + return false; + } + // Real signs can alternate secondary alpha between words. Texture payloads switch it + // per glyph, so sparse word-level styling must not seed a texture-font group. + if (glyphs.length > secondaryAlpha.length * MAX_TEXTURE_GLYPHS_PER_ALPHA_OVERRIDE) { + return false; + } + const alphaValues = secondaryAlpha.map((command) => command.args.toLowerCase()); + return new Set(alphaValues).size >= 2; +} + +function staticGlobalAlpha(cue: AnnotatedSubtitleCue): number | null { + return staticAlphaOverride(cue, 'alpha'); +} + +function staticAlphaOverride(cue: AnnotatedSubtitleCue, expectedName: string): number | null { + let alpha: number | null = null; + for (const command of cue.overrides) { + if (command.animated || command.name.toLowerCase() !== expectedName) continue; + const match = ASS_ALPHA_VALUE_PATTERN.exec(command.args.trim()); + const alphaValue = match?.[1]; + if (alphaValue !== undefined) { + alpha = Number.parseInt(alphaValue, 16); + } + } + return alpha; +} + +function hasAnimatedAlphaOverride(cue: AnnotatedSubtitleCue): boolean { + return cue.overrides.some((command) => { + if (!command.animated) return false; + const name = command.name.toLowerCase(); + return name === '1a' || name === 'alpha'; + }); +} + +// `\alpha&HFF&` blanks all four layers, but a later component override can turn one +// back on: chant overlays render entirely through `\4a&H00&` shadows. Any re-enabled +// layer means the event draws real text. +const ASS_COMPONENT_ALPHA_NAMES = ['1a', '2a', '3a', '4a'] as const; + +function hasVisibleComponentAlpha(cue: AnnotatedSubtitleCue): boolean { + return ASS_COMPONENT_ALPHA_NAMES.some((name) => { + const value = staticAlphaOverride(cue, name); + return value !== null && value < 0xff; + }); +} + +/** + * A glyph copy whose fill is statically fully transparent and never animated back in is + * a glow or outline echo of the real glyph, not the text itself. A coalesced copy chain + * counts only when every phase in it is such an echo; one opaque phase means the chain + * carries the authored glyph. + */ +function isTransparentFillEcho(cue: AnnotatedSubtitleCue): boolean { + return sourceEventsOf(cue).every( + (source) => + (staticAlphaOverride(source, '1a') === 0xff || + staticAlphaOverride(source, 'alpha') === 0xff) && + !hasAnimatedAlphaOverride(source) && + !hasVisibleComponentAlpha(source), + ); +} + +function staticFontSize(cue: AnnotatedSubtitleCue): number | null { + let fontSize: number | null = null; + for (const command of cue.overrides) { + if (command.animated || command.name.toLowerCase() !== 'fs') continue; + const value = Number(command.args.trim()); + if (Number.isFinite(value) && value > 0) { + fontSize = value; + } + } + return fontSize; +} + +function isNearlyTransparentPositionedText(cue: AnnotatedSubtitleCue): boolean { + const alpha = staticGlobalAlpha(cue); + return ( + alpha !== null && + alpha >= MIN_TEXTURE_LAYER_ALPHA && + staticFontOverride(cue) !== null && + fragmentPosition(cue) !== null + ); +} + +function hasAssTextureCandidateEvidence(cue: AnnotatedSubtitleCue): boolean { + return ( + isClippedRepeatedGlyphFragment(cue) || + isNearlyTransparentPositionedText(cue) || + hasStaticOverride(cue, '2a') + ); +} + +function textureFontFamilyKey(font: string): string { + return font.replace(ASS_FONT_WEIGHT_SUFFIX_PATTERN, ''); +} + +function isTextureFontPayload( + cue: AnnotatedSubtitleCue, + textureFontFamilies: ReadonlySet, +): boolean { + const font = staticFontOverride(cue); + const fontSize = staticFontSize(cue); + const visibleLines = cue.text.split('\n').filter((line) => line.trim().length > 0); + return ( + font !== null && + textureFontFamilies.has(textureFontFamilyKey(font)) && + fontSize !== null && + fontSize <= MAX_TEXTURE_PAYLOAD_FONT_SIZE && + staticGlobalAlpha(cue) !== null && + fragmentPosition(cue) !== null && + visibleLines.length >= MIN_TEXTURE_PAYLOAD_LINES + ); +} + +// A sign translation can legitimately render faint text through one or two positioned +// events. Dozens of them sharing one window is a texture: near-invisible glyph strings +// laid out as pixels of an image, with no visible-text sibling to anchor them. +const MIN_TEXTURE_WALL_EVENTS = 6; + +function textureWallGroupKey(cue: AnnotatedSubtitleCue): string { + return `${cue.style}\0${cue.startTime}\0${cue.endTime}`; +} + +/** Static zero scale or a degenerate static clip renders nothing, unless animation can + * still bring the event into view (an entrance growing from `\fscx0`, a clip wipe). + * Only an animated scale or clip reveals; `\t(...)` wrapping some other property leaves + * the event invisible. Nested `\t(...)` needs no special case because the tags it + * animates are recorded as animated in their own right. */ +function isInvisiblyRenderedEvent(cue: AnnotatedSubtitleCue): boolean { + let staticZeroScale = false; + let staticZeroClip = false; + let animatedReveal = false; + for (const command of cue.overrides) { + const name = command.name.toLowerCase(); + if (command.animated) { + if (name === 'fscx' || name === 'fscy' || name === 'clip') { + animatedReveal = true; + } + continue; + } + if (name === 'fscx' || name === 'fscy') { + if (Number(command.args.trim()) === 0) { + staticZeroScale = true; + } + } else if (name === 'clip') { + const args = command.args.split(',').map((value) => Number(value.trim())); + if ( + args.length >= 4 && + args.slice(0, 4).every(Number.isFinite) && + (args[0]! >= args[2]! || args[1]! >= args[3]!) + ) { + staticZeroClip = true; + } + } + } + return (staticZeroScale || staticZeroClip) && !animatedReveal; +} + +function assFontTextureGroupKey(cue: AnnotatedSubtitleCue): string | null { + const font = staticFontOverride(cue); + return font === null ? null : `${cue.style}\0${cue.startTime}\0${cue.endTime}\0${font}`; +} + +function assTextureTimingGroupKey(cue: AnnotatedSubtitleCue): string { + return `${cue.style}\0${cue.startTime}\0${cue.endTime}`; +} + +function removeAssFontTextureEvents(events: ParsedAssEvents): ParsedAssEvents { + const seeds = events.dialogue.filter(isAssTextureSeed); + const seedSet = new Set(seeds); + // Short pieces can share the seeded font effect under another actor. Matching the + // seed's style, timing, and font only narrows the candidates; each piece must still + // carry structural texture evidence. + const textureGroups = new Set( + seeds.map(assFontTextureGroupKey).filter((key): key is string => key !== null), + ); + const noFontTextureTimings = new Set( + seeds + .filter((seed) => staticFontOverride(seed) === null) + .map((seed) => assTextureTimingGroupKey(seed)), + ); + // Some signs switch actor and font between the texture mask and its payload. A nearly + // transparent text event that overlaps a proven seed in the same style is another input + // to that visual effect. Opaque authored text in the same sign remains publishable. + const seedsByStyle = new Map(); + for (const seed of seeds) { + const styleSeeds = seedsByStyle.get(seed.style); + if (styleSeeds) { + styleSeeds.push(seed); + } else { + seedsByStyle.set(seed.style, [seed]); + } + } + const seedIndexesByStyle = new Map( + [...seedsByStyle].map(([style, styleSeeds]) => [style, buildAssEventGroupIndex(styleSeeds)]), + ); + const isAssociatedWithTextureSeed = (cue: AnnotatedSubtitleCue): boolean => { + const styleSeedIndex = seedIndexesByStyle.get(cue.style); + return ( + styleSeedIndex !== undefined && + eventsOverlappingWindow(styleSeedIndex, cue.startTime, cue.endTime).length > 0 + ); + }; + const textureFontFamilies = new Set( + [ + ...seeds, + ...events.dialogue.filter( + (cue) => isNearlyTransparentPositionedText(cue) && isAssociatedWithTextureSeed(cue), + ), + ...events.comments.filter( + (cue) => isNearlyTransparentPositionedText(cue) && isAssociatedWithTextureSeed(cue), + ), + ] + .map(staticFontOverride) + .filter((font): font is string => font !== null) + .map(textureFontFamilyKey), + ); + + // Wall membership additionally requires that no component alpha turns a layer back + // on: `\alpha&HFF&` plus a visible `\4a` renders real text through its shadow, and a + // sign typeset entirely from such layers must not read as a texture. + const isTextureWallCandidate = (cue: AnnotatedSubtitleCue): boolean => + isNearlyTransparentPositionedText(cue) && !hasVisibleComponentAlpha(cue); + const transparentWallCounts = new Map(); + for (const cue of events.dialogue) { + if (isTextureWallCandidate(cue)) { + const key = textureWallGroupKey(cue); + transparentWallCounts.set(key, (transparentWallCounts.get(key) ?? 0) + 1); + } + } + + return { + dialogue: events.dialogue.filter((cue) => { + if (isInvisiblyRenderedEvent(cue)) { + return false; + } + if (seedSet.has(cue)) { + return false; + } + if ( + isTextureWallCandidate(cue) && + (transparentWallCounts.get(textureWallGroupKey(cue)) ?? 0) >= MIN_TEXTURE_WALL_EVENTS + ) { + return false; + } + if ( + staticFontOverride(cue) === null && + noFontTextureTimings.has(assTextureTimingGroupKey(cue)) && + isClippedRepeatedGlyphFragment(cue) + ) { + return false; + } + const key = assFontTextureGroupKey(cue); + if (key !== null && textureGroups.has(key) && hasAssTextureCandidateEvidence(cue)) { + return false; + } + if (isTextureFontPayload(cue, textureFontFamilies)) { + return false; + } + if (!isNearlyTransparentPositionedText(cue)) { + return true; + } + return !isAssociatedWithTextureSeed(cue); + }), + comments: events.comments, + }; +} + +/** + * Generated lyric effects often layer decoration over the real syllables: single letters + * positioned above each glyph, animated in, and rendered through a `\fn` override to a + * symbol font where `a` draws as a sparkle rather than a letter. Reading them as text + * corrupts the reconstructed line (`sotto mimi ni ateru to` gains a trailing `a z x`). + * Within one style/name group, a font used only for scattered animated single glyphs -- + * while the group's actual text renders in another font -- marks those events as + * decoration rather than dialogue. + */ +function decorativeGlyphEvents(events: readonly AnnotatedSubtitleCue[]): Set { + const byFont = new Map(); + for (const cue of events) { + const font = staticFontOverride(cue); + if (font === null) continue; + const group = byFont.get(font); + if (group) { + group.push(cue); + } else { + byFont.set(font, [cue]); + } + } + + const decorative = new Set(); + for (const fontEvents of byFont.values()) { + if (fontEvents.length * 2 >= events.length) continue; + const allScatteredGlyphs = fontEvents.every( + (cue) => + [...compactCueMatchText(cue)].length === 1 && + fragmentPosition(cue) !== null && + hasAssTemporalOverride(cue.overrides), + ); + if (allScatteredGlyphs) { + fontEvents.forEach((cue) => decorative.add(cue)); + } + } + return decorative; +} + +function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] { + const groups = new Map(); + for (const cue of dialogue) { + if (cue.source !== undefined) { + continue; + } + const key = assEventGroupKey(cue); + const group = groups.get(key); + if (group) { + group.push(cue); + } else { + groups.set(key, [cue]); + } + } + + const recovered: AnnotatedSubtitleCue[] = []; + const suppressed = new Set(); + // The published dialogue list holds source events, so a suppressed coalesced copy + // chain must suppress every event behind it. + const suppress = (event: AnnotatedSubtitleCue): void => { + for (const source of sourceEventsOf(event)) { + suppressed.add(source); + } + }; + for (const events of groups.values()) { + const units = coalesceAssAnchorCopies(events); + const decorative = decorativeGlyphEvents(units); + for (const unit of units) { + if ( + !decorative.has(unit) && + fragmentPlacementAnchors(unit).size > 0 && + isTransparentFillEcho(unit) + ) { + decorative.add(unit); + } + } + const lineEvents = decorative.size ? units.filter((event) => !decorative.has(event)) : units; + if (isProgressiveHighlightSweepGroup(lineEvents)) { + lineEvents.forEach(suppress); + const spanStart = Math.min(...lineEvents.map((event) => event.startTime)); + const spanEnd = Math.max(...lineEvents.map((event) => event.endTime)); + for (const overlay of decorative) { + if ( + overlay.startTime < spanEnd + DECORATION_SPAN_TOLERANCE_SECONDS && + overlay.endTime > spanStart - DECORATION_SPAN_TOLERANCE_SECONDS + ) { + suppress(overlay); + } + } + continue; + } + for (const cluster of clusterAssFragmentEvents(lineEvents)) { + const line = reconstructAssFragmentLine(cluster.events); + if (!line) { + continue; + } + // A sweep only re-highlights the lyric it decorates: hide its events without + // publishing the reconstruction. + if (isProgressiveHighlightSweep(cluster.events)) { + cluster.events.forEach(suppress); + continue; + } + recovered.push(line); + cluster.events.forEach(suppress); + // Decoration is timed to the line it overlays, so it disappears with the line's + // full animation span. Decoration outside any recovered span stays published. + const spanStart = line.animationStartTime ?? line.startTime; + const spanEnd = line.animationEndTime ?? line.endTime; + for (const overlay of decorative) { + if ( + overlay.startTime < spanEnd + DECORATION_SPAN_TOLERANCE_SECONDS && + overlay.endTime > spanStart - DECORATION_SPAN_TOLERANCE_SECONDS + ) { + suppress(overlay); + } + } + } + } + if (recovered.length === 0 && suppressed.size === 0) { + return dialogue; + } + return [ + ...dialogue.filter((cue) => !suppressed.has(cue)), + ...mergeAbuttingRecoveredLines(recovered), + ].sort( + (left, right) => + left.startTime - right.startTime || left.endTime - right.endTime || left.order - right.order, + ); +} + +// One authored line can reconstruct twice from consecutive effect stages -- its steady +// glyphs, then an exit animation replaying the same text. Publishing both would show the +// line restarting, so identical recoveries that touch in time collapse into one span. +const RECOVERED_LINE_MERGE_GAP_SECONDS = 0.1; + +function mergeAbuttingRecoveredLines(recovered: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] { + const byText = new Map(); + for (const line of recovered) { + const key = `${assEventGroupKey(line)}\0${compactAssMatchText(line.text)}`; + const bucket = byText.get(key); + if (bucket) { + bucket.push(line); + } else { + byText.set(key, [line]); + } + } + + const merged: AnnotatedSubtitleCue[] = []; + for (const bucket of byText.values()) { + bucket.sort((left, right) => left.startTime - right.startTime || left.order - right.order); + let current = bucket[0]!; + for (let index = 1; index < bucket.length; index += 1) { + const next = bucket[index]!; + if (next.startTime <= current.endTime + RECOVERED_LINE_MERGE_GAP_SECONDS) { + const endTime = Math.max(current.endTime, next.endTime); + current = { + ...current, + endTime, + animationEndTime: Math.max( + current.animationEndTime ?? current.endTime, + next.animationEndTime ?? next.endTime, + ), + }; + } else { + merged.push(current); + current = next; + } + } + merged.push(current); + } + return merged; +} + +function groupConsecutiveAssFragments(events: readonly AnnotatedSubtitleCue[]): FragmentGroup[] { + const groups: FragmentGroup[] = []; + for (const event of events) { + const text = compactCueMatchText(event); + if (!text) { + continue; + } + const previous = groups.at(-1); + if ( + previous?.text === text && + previous.events.some((previousEvent) => isRepeatedFragmentCopy(previousEvent, event)) + ) { + previous.events.push(event); + } else { + groups.push({ text, events: [event] }); + } + } + return groups; +} + +function findCanonicalFragmentEvents( + events: readonly AnnotatedSubtitleCue[], + canonicalText: string, +): AnnotatedSubtitleCue[] { + const groups = groupConsecutiveAssFragments(events); + const matches = new Set(); + + for (let start = 0; start < groups.length; start += 1) { + let combined = ''; + for (let end = start; end < groups.length; end += 1) { + const group = groups[end]!; + // A complete rendered copy cannot prove that the neighboring events are its + // fragments. Exact full-line animation is handled separately for comments. + if (group.text.length >= canonicalText.length) { + break; + } + const next = combined + group.text; + if (!canonicalText.startsWith(next)) { + break; + } + combined = next; + if (combined !== canonicalText) { + continue; + } + for (let index = start; index <= end; index += 1) { + for (const event of groups[index]!.events) { + matches.add(event); + } + } + start = end; + break; + } + } + + return [...matches]; +} + +function matchingAssAnimationEvents(options: { + candidate: AnnotatedSubtitleCue; + group: AssEventGroupIndex; + allowFullLineFrames: boolean; +}): AnnotatedSubtitleCue[] { + const canonicalText = compactCueMatchText(options.candidate); + // The group index already restricts to the candidate's style and name. + const nearby = eventsOverlappingWindow( + options.group, + options.candidate.startTime - CANONICAL_MATCH_MARGIN_SECONDS, + options.candidate.endTime + CANONICAL_MATCH_MARGIN_SECONDS, + ); + const fragments = findCanonicalFragmentEvents(nearby, canonicalText); + if (fragments.length >= MIN_CANONICAL_ANIMATION_EVENTS && hasAssAnimationEvidence(fragments)) { + return fragments; + } + + if (!options.allowFullLineFrames) { + return []; + } + const fullLineFrames = nearby.filter((cue) => compactCueMatchText(cue) === canonicalText); + return fullLineFrames.length >= MIN_CANONICAL_ANIMATION_EVENTS && + hasAssAnimationEvidence(fullLineFrames) + ? fullLineFrames + : []; +} + +// Reductions rather than `Math.min(...events)`: one generated line can carry an +// unbounded number of events, and spreading them all as arguments risks the engine's +// argument-count limit. +function earliestStartTime(events: readonly AnnotatedSubtitleCue[], seed = Infinity): number { + return events.reduce((earliest, event) => Math.min(earliest, event.startTime), seed); +} + +function latestEndTime(events: readonly AnnotatedSubtitleCue[], seed = -Infinity): number { + return events.reduce((latest, event) => Math.max(latest, event.endTime), seed); +} + +function includeCanonicalBoundaryEvents(options: { + candidate: AnnotatedSubtitleCue; + group: AssEventGroupIndex; + animationEvents: readonly AnnotatedSubtitleCue[]; +}): AnnotatedSubtitleCue[] { + const canonicalText = compactCueMatchText(options.candidate); + const startTime = earliestStartTime(options.animationEvents); + const endTime = latestEndTime(options.animationEvents); + return eventsOverlappingWindow( + options.group, + startTime - CANONICAL_MATCH_MARGIN_SECONDS, + endTime + CANONICAL_MATCH_MARGIN_SECONDS, + ).filter((cue) => compactCueMatchText(cue) === canonicalText); +} + +function recoverCanonicalAssEvents({ + dialogue, + comments, +}: ParsedAssEvents): AnnotatedSubtitleCue[] { + const recovered: AnnotatedSubtitleCue[] = []; + const suppressed = new Set(); + // A recovery is only as good as its owning event. When a later candidate proves that + // an earlier candidate was itself a generated frame of its animation, the earlier + // recovery is a duplicate of the same authored line and must be withdrawn. + const recoveredByOwner = new Map(); + const withdrawn = new Set(); + const eventsByGroup = new Map(); + for (const cue of dialogue) { + const key = assEventGroupKey(cue); + const group = eventsByGroup.get(key); + if (group) { + group.push(cue); + } else { + eventsByGroup.set(key, [cue]); + } + } + const indexByGroup = new Map(); + for (const [key, events] of eventsByGroup) { + indexByGroup.set(key, buildAssEventGroupIndex(events)); + } + const emptyGroupIndex: AssEventGroupIndex = { byStart: [], prefixMaxEnd: [] }; + const candidates = [ + ...comments.map((cue) => ({ cue, kind: 'comment' as const })), + ...dialogue + .filter( + (cue) => + compactCueMatchText(cue).length >= MIN_CANONICAL_DIALOGUE_TEXT_LENGTH && + (cue.assLayout?.kind === 'source-order' || hasAssAnimationEvidence([cue])), + ) + .sort((left, right) => right.text.length - left.text.length || left.order - right.order) + .map((cue) => ({ cue, kind: 'dialogue' as const })), + ]; + + for (const { cue: candidate, kind } of candidates) { + if (candidate.endTime <= candidate.startTime || suppressed.has(candidate)) { + continue; + } + const canonicalText = compactCueMatchText(candidate); + if (!canonicalText) { + continue; + } + + const group = indexByGroup.get(assEventGroupKey(candidate)) ?? emptyGroupIndex; + const animationEvents = matchingAssAnimationEvents({ + candidate, + group, + allowFullLineFrames: kind === 'comment', + }); + if (animationEvents.length === 0) { + continue; + } + + const boundaryEvents = includeCanonicalBoundaryEvents({ + candidate, + group, + animationEvents, + }); + const generatedEvents = [...new Set([...animationEvents, ...boundaryEvents])]; + const animationStartTime = earliestStartTime(generatedEvents, candidate.startTime); + const animationEndTime = latestEndTime(generatedEvents, candidate.endTime); + const startTime = kind === 'comment' ? candidate.startTime : animationStartTime; + const endTime = kind === 'comment' ? candidate.endTime : animationEndTime; + const assFurigana = [ + ...new Set([candidate, ...generatedEvents].flatMap((cue) => cue.assFurigana ?? [])), + ]; + const recoveredCue: AnnotatedSubtitleCue = { + ...candidate, + startTime, + endTime, + animationStartTime, + animationEndTime, + source: 'canonical-ass', + ...(assFurigana.length === 0 ? {} : { assFurigana }), + }; + recovered.push(recoveredCue); + recoveredByOwner.set(candidate, recoveredCue); + for (const event of generatedEvents) { + suppressed.add(event); + if (event === candidate) { + continue; + } + const priorRecovery = recoveredByOwner.get(event); + if (priorRecovery) { + // No text is lost by withdrawing: a fragment claim means the withdrawn line is + // a contiguous piece of this candidate's text, and a boundary claim means the + // texts are equal, so the surviving canonical cue always contains it. + withdrawn.add(priorRecovery); + } + } + } + + const survivingRecovered = recovered.filter((cue) => !withdrawn.has(cue)); + if (survivingRecovered.length === 0) { + return dialogue; + } + return [...dialogue.filter((cue) => !suppressed.has(cue)), ...survivingRecovered].sort( + (a, b) => a.startTime - b.startTime || a.endTime - b.endTime || a.order - b.order, + ); +} + +function bandFromNumpadAlignment(alignment: number): AssVerticalBand | null { + if (alignment >= 7 && alignment <= 9) return 'top'; + if (alignment >= 4 && alignment <= 6) return 'middle'; + if (alignment >= 1 && alignment <= 3) return 'bottom'; + return null; +} + +// SSA v4 alignment reuses the legacy `\a` codes: 1-3 bottom, +4 top, +8 middle. +function bandFromLegacyAlignment(alignment: number): AssVerticalBand | null { + if (alignment >= 9 && alignment <= 11) return 'middle'; + if (alignment >= 5 && alignment <= 7) return 'top'; + if (alignment >= 1 && alignment <= 3) return 'bottom'; + return null; +} + +interface AssPlacementContext { + playResY: number | null; + /** Lowercased style name -> vertical band from the style's Alignment column. */ + styleBands: Map; +} + +const EMPTY_PLACEMENT_CONTEXT: AssPlacementContext = { playResY: null, styleBands: new Map() }; + +function parseAssPlacementContext(content: string): AssPlacementContext { + const styleBands = new Map(); + let playResY: number | null = null; + let section: 'info' | 'v4plus' | 'v4' | null = null; + let alignmentIndex = -1; + let nameIndex = -1; + + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + const sectionName = trimmed.toLowerCase(); + section = + sectionName === '[script info]' + ? 'info' + : sectionName === '[v4+ styles]' + ? 'v4plus' + : sectionName === '[v4 styles]' + ? 'v4' + : null; + alignmentIndex = -1; + nameIndex = -1; + continue; + } + if (section === 'info') { + const resMatch = trimmed.match(/^playresy\s*:\s*(\d+(?:\.\d+)?)\s*$/i); + if (resMatch) playResY = Number(resMatch[1]); + continue; + } + if (section !== 'v4plus' && section !== 'v4') continue; + const separator = trimmed.indexOf(':'); + if (separator < 0) continue; + const key = trimmed.slice(0, separator).trim().toLowerCase(); + const fields = trimmed.slice(separator + 1).split(','); + if (key === 'format') { + const names = fields.map((field) => field.trim().toLowerCase()); + alignmentIndex = names.indexOf('alignment'); + nameIndex = names.indexOf('name'); + continue; + } + if (key !== 'style' || alignmentIndex < 0 || nameIndex < 0) continue; + const styleName = fields[nameIndex]?.trim().toLowerCase(); + const alignment = Number(fields[alignmentIndex]?.trim()); + if (!styleName || !Number.isFinite(alignment)) continue; + const band = + section === 'v4plus' + ? bandFromNumpadAlignment(alignment) + : bandFromLegacyAlignment(alignment); + if (band) styleBands.set(styleName, band); + } + + return { playResY, styleBands }; +} + +/** + * Where on screen mpv will draw this event: an explicit `\pos`/`\move` coordinate when + * the script declares its coordinate space, else an `\an`/`\a` override, else the + * style's Alignment. Constant for the life of the event, which is what lets simultaneous + * lines keep a stable stacking order in the overlay. + */ +function resolveVerticalBand( + overrides: readonly AssOverrideCommand[], + y: number | null, + style: string, + context: AssPlacementContext, +): AssVerticalBand | undefined { + if (y !== null && context.playResY && context.playResY > 0) { + const ratio = y / context.playResY; + return ratio < 1 / 3 ? 'top' : ratio < 2 / 3 ? 'middle' : 'bottom'; + } + for (const command of overrides) { + if (command.animated) continue; + const name = command.name.toLowerCase(); + if (name !== 'an' && name !== 'a') continue; + const band = + name === 'an' + ? bandFromNumpadAlignment(Number(command.args)) + : bandFromLegacyAlignment(Number(command.args)); + if (band) return band; + } + return context.styleBands.get(style.trim().toLowerCase()); +} + +function parseAssCoordinate(value: string | undefined): number | null { + if (!value?.trim()) return null; + const coordinate = Number(value.trim()); + return Number.isFinite(coordinate) ? coordinate : null; +} + +function buildAssCueLayout( + overrides: readonly AssOverrideCommand[], + sourceOrder: number, + style: string, + placement: AssPlacementContext, +): AssCueLayout { + let x: number | null = null; + let y: number | null = null; + for (const command of overrides) { + if (command.animated) continue; + const name = command.name.toLowerCase(); + const args = command.args.split(','); + if (name === 'pos') { + x = parseAssCoordinate(args[0]) ?? x; + y = parseAssCoordinate(args[1]) ?? y; + continue; + } + if (name !== 'move') continue; + const startX = parseAssCoordinate(args[0]); + const startY = parseAssCoordinate(args[1]); + const endX = parseAssCoordinate(args[2]); + const endY = parseAssCoordinate(args[3]); + if (startX !== null && endX !== null) { + x = (startX + endX) / 2; + } + if (startY !== null && endY !== null) { + y = (startY + endY) / 2; + } + } + const verticalBand = resolveVerticalBand(overrides, y, style, placement); + const base: AssCueLayout = + y === null + ? { kind: 'source-order', sourceOrder } + : { kind: 'positioned', sourceOrder, ...(x === null ? {} : { x }), y }; + return verticalBand ? { ...base, verticalBand } : base; +} + +const ASS_FURIGANA_TEXT_PATTERN = /^[\p{Script=Hiragana}\p{Script=Katakana}ー・ \t\u3000]+$/u; +const ASS_KANJI_PATTERN = /\p{Script=Han}/u; +const MAX_ASS_FURIGANA_SCALE_PERCENT = 60; +// The pixel geometry below is authored in the 540-line coordinate space Caption2Ass-style +// broadcast CC converters emit, and is multiplied by PlayResY/540 so the same on-screen +// window applies to scripts declaring other resolutions. Without a declaration the tuned +// space is assumed. +const ASS_FURIGANA_REFERENCE_PLAY_RES_Y = 540; +const MIN_ASS_FURIGANA_BASE_GAP = 40; +const MAX_ASS_FURIGANA_BASE_GAP = 68; +const MIN_ASS_FURIGANA_HORIZONTAL_TOLERANCE = 80; +const ASS_BASE_CHARACTER_WIDTH_ESTIMATE = 40; + +function assFuriganaGeometryScale(playResY: number | null): number { + return playResY && playResY > 0 ? playResY / ASS_FURIGANA_REFERENCE_PLAY_RES_Y : 1; +} + +function staticAssScalePercent(cue: AnnotatedSubtitleCue, axis: 'fscx' | 'fscy'): number | null { + let scale: number | null = null; + for (const command of cue.overrides) { + if (command.animated || command.name.toLowerCase() !== axis) continue; + const value = Number(command.args.trim()); + if (Number.isFinite(value) && value > 0) { + scale = value; + } + } + return scale; +} + +function isAssFuriganaCandidate(cue: AnnotatedSubtitleCue): boolean { + const scaleX = staticAssScalePercent(cue, 'fscx'); + const scaleY = staticAssScalePercent(cue, 'fscy'); + return ( + cue.assLayout?.kind === 'positioned' && + ASS_FURIGANA_TEXT_PATTERN.test(cue.text) && + scaleX !== null && + scaleX <= MAX_ASS_FURIGANA_SCALE_PERCENT && + scaleY !== null && + scaleY <= MAX_ASS_FURIGANA_SCALE_PERCENT + ); +} + +function findAssFuriganaBase( + furigana: AnnotatedSubtitleCue, + cues: readonly AnnotatedSubtitleCue[], + geometryScale: number, +): AnnotatedSubtitleCue | null { + if (furigana.assLayout?.kind !== 'positioned' || furigana.assLayout.x === undefined) { + return null; + } + + let nearest: { cue: AnnotatedSubtitleCue; gap: number } | null = null; + for (const cue of cues) { + if ( + cue === furigana || + cue.startTime !== furigana.startTime || + cue.endTime !== furigana.endTime || + cue.style !== furigana.style || + cue.layer !== furigana.layer || + cue.name !== furigana.name || + cue.assLayout?.kind !== 'positioned' || + !ASS_KANJI_PATTERN.test(cue.text) + ) { + continue; + } + const scaleY = staticAssScalePercent(cue, 'fscy'); + if (scaleY !== null && scaleY <= MAX_ASS_FURIGANA_SCALE_PERCENT) continue; + + const gap = cue.assLayout.y - furigana.assLayout.y; + if ( + gap < MIN_ASS_FURIGANA_BASE_GAP * geometryScale || + gap > MAX_ASS_FURIGANA_BASE_GAP * geometryScale + ) { + continue; + } + if (cue.assLayout.x === undefined) continue; + const baseCharacterCount = [...cue.text.replace(/[ \t\u3000]/g, '')].length; + const horizontalTolerance = + Math.max( + MIN_ASS_FURIGANA_HORIZONTAL_TOLERANCE, + baseCharacterCount * ASS_BASE_CHARACTER_WIDTH_ESTIMATE, + ) * geometryScale; + if (Math.abs(cue.assLayout.x - furigana.assLayout.x) > horizontalTolerance) continue; + if (!nearest || gap < nearest.gap || (gap === nearest.gap && cue.order < nearest.cue.order)) { + nearest = { cue, gap }; + } + } + return nearest?.cue ?? null; +} + +function removeAssFuriganaFromCueList( + cues: AnnotatedSubtitleCue[], + geometryScale: number, +): AnnotatedSubtitleCue[] { + const removed = new Set(); + for (const cue of cues) { + if (!isAssFuriganaCandidate(cue)) continue; + const base = findAssFuriganaBase(cue, cues, geometryScale); + if (!base) continue; + base.assFurigana = [...new Set([...(base.assFurigana ?? []), cue.text])]; + removed.add(cue); + } + return removed.size === 0 ? cues : cues.filter((cue) => !removed.has(cue)); +} + +function removeAssFuriganaEvents( + events: ParsedAssEvents, + playResY: number | null, +): ParsedAssEvents { + const geometryScale = assFuriganaGeometryScale(playResY); + return { + dialogue: removeAssFuriganaFromCueList(events.dialogue, geometryScale), + comments: removeAssFuriganaFromCueList(events.comments, geometryScale), + }; +} + +function parseAnnotatedAssEvents(content: string, placement: AssPlacementContext): ParsedAssEvents { const cues: AnnotatedSubtitleCue[] = []; + const comments: AnnotatedSubtitleCue[] = []; const lines = content.split(/\r?\n/); let inEventsSection = false; + let eventOrder = 0; const fieldIndex = { start: -1, end: -1, @@ -192,6 +2370,10 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] { for (const line of lines) { const trimmed = line.trim(); + // Event text can end in an authored space. Fragmented karaoke commonly uses that + // space to retain word boundaries when its separately positioned events are joined + // back into a line, so only remove indentation before slicing the event fields. + const eventLine = line.trimStart(); if (trimmed.startsWith('[') && trimmed.endsWith(']')) { inEventsSection = trimmed.toLowerCase() === '[events]'; @@ -222,7 +2404,12 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] { continue; } - if (!trimmed.startsWith(ASS_DIALOGUE_PREFIX)) { + const eventPrefix = eventLine.startsWith(ASS_DIALOGUE_PREFIX) + ? ASS_DIALOGUE_PREFIX + : eventLine.startsWith(ASS_COMMENT_PREFIX) + ? ASS_COMMENT_PREFIX + : null; + if (!eventPrefix) { continue; } @@ -230,7 +2417,7 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] { continue; } - const fields = trimmed.slice(ASS_DIALOGUE_PREFIX.length).split(','); + const fields = eventLine.slice(eventPrefix.length).split(','); if ( fieldIndex.start >= fields.length || fieldIndex.end >= fields.length || @@ -241,12 +2428,12 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] { const startTime = parseAssTimestamp(fields[fieldIndex.start]!); const endTime = parseAssTimestamp(fields[fieldIndex.end]!); - if (startTime === null || endTime === null) { + if (startTime === null || endTime === null || endTime <= startTime) { continue; } const rawText = fields.slice(fieldIndex.text).join(','); - const text = sanitizeSubtitleCueText(rawText); + const text = sanitizeAssCueText(rawText); if (!text) { continue; } @@ -254,23 +2441,42 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] { const effect = readField(fields, fieldIndex.effect); const layer = Number(readField(fields, fieldIndex.layer)); const overrides = collectAssOverrideCommands(rawText); - cues.push({ + const style = readField(fields, fieldIndex.style); + const cue: AnnotatedSubtitleCue = { startTime, endTime, text, rawText, - style: readField(fields, fieldIndex.style), + style, layer: Number.isFinite(layer) ? layer : 0, name: readField(fields, fieldIndex.name), effect, effectKind: parseAssEffectField(effect), overrides, overrideSignature: assOverrideSignature(overrides), - order: cues.length, - }); + order: eventOrder, + assLayout: buildAssCueLayout(overrides, eventOrder, style, placement), + }; + eventOrder += 1; + if (eventPrefix === ASS_COMMENT_PREFIX) { + comments.push(cue); + } else { + cues.push(cue); + } } - return cues; + return { dialogue: cues, comments }; +} + +function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] { + const placement = content.includes('[') + ? parseAssPlacementContext(content) + : EMPTY_PLACEMENT_CONTEXT; + const events = removeAssFuriganaEvents( + removeAssFontTextureEvents(parseAnnotatedAssEvents(content, placement)), + placement.playResY, + ); + return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events)); } export function parseAssCues(content: string): SubtitleCue[] { diff --git a/src/core/services/subtitle-processing-controller.ts b/src/core/services/subtitle-processing-controller.ts index 3097e1e2..d03d8c88 100644 --- a/src/core/services/subtitle-processing-controller.ts +++ b/src/core/services/subtitle-processing-controller.ts @@ -134,7 +134,7 @@ export function createSubtitleProcessingController( try { const cachedTokenized = getCachedTokenization(text); if (cachedTokenized) { - output = cachedTokenized; + output = { ...cachedTokenized, text }; } else { // Cache miss: show the plain line on time; the tokenized payload // upgrades it once ready. Skipped on refreshes of an already @@ -266,7 +266,7 @@ export function createSubtitleProcessingController( lastEmittedText = text; lastEmittedGeneration = cacheGeneration; lastPlainEmittedText = null; - return cached; + return { ...cached, text }; }, hasCachedSubtitle: (text: string) => { const cacheKey = normalizeSubtitleCacheKey(text); diff --git a/src/core/services/subtitle-timing-offset.test.ts b/src/core/services/subtitle-timing-offset.test.ts deleted file mode 100644 index 15cad6e6..00000000 --- a/src/core/services/subtitle-timing-offset.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { estimateSubtitleTimingOffset } from './subtitle-timing-offset'; - -function cue(startTime: number) { - return { startTime, endTime: startTime + 1, text: `cue ${startTime}` }; -} - -test('estimate subtitle timing offset detects a late Jellyfin subtitle timeline', () => { - const primary = [ - 34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814, - 87.988, 90.991, 94.094, 97.097, - ].map(cue); - const reference = [ - 3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56, - ].map(cue); - - const result = estimateSubtitleTimingOffset(primary, reference); - - assert.ok(result); - assert.ok(result.offsetSeconds > -32); - assert.ok(result.offsetSeconds < -31); - assert.ok(result.matchCount >= 8); - assert.ok(result.meanErrorSeconds <= 0.75); -}); - -test('estimate subtitle timing offset favors the early episode timeline', () => { - const primary = [ - 34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814, - 87.988, 90.991, 94.094, 97.097, 207.974, 212.579, 222.422, 228.095, 232.432, 238.271, 244.778, - 246.78, 249.282, 251.284, 253.62, 256.289, 259.626, 262.129, 264.965, 267.634, 270.303, 274.407, - 277.077, 280.08, 284.084, 288.421, 291.925, 295.262, 298.431, 301.101, 306.773, 308.942, - 312.946, 316.283, 321.621, 326.626, 331.131, 336.069, 340.407, 343.41, 351.418, 355.422, - 357.924, 362.429, 365.432, 370.604, 373.273, 377.944, 381.114, 384.618, 387.621, 390.957, - 396.73, 399.232, 401.568, 403.57, 405.572, 407.574, 409.743, 412.746, 418.752, 425.258, 427.26, - 435.602, 440.44, 442.942, 445.445, 449.783, - ].map(cue); - const reference = [ - 3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56, 165.77, 172.81, - 176.1, 177.27, 186.33, 191.33, 195.78, 201.83, 212.9, 214.09, 216.73, 220.2, 222.91, 225.65, - 232.8, 237.92, 242.23, 243.28, 247.53, 252.04, 255.9, 258.86, 262.09, 264.43, 276.07, 278.01, - 280.98, 285.67, 289.89, 294.57, 300, 303.56, 308.58, 316.37, 318.38, 319.86, 325.38, 328.82, - 333.68, 335.26, 336.82, 340.11, 342.11, 344.36, 346.39, 347.53, 350.92, 370.18, 372.88, 376.43, - 388.2, 390.57, 403.96, 406.36, 409.72, 413.78, 425.55, 432.76, 435.03, 438.06, 443.73, 448.31, - 450.57, 457.62, 463.41, 465.85, 473.79, 480.59, - ].map(cue); - - const result = estimateSubtitleTimingOffset(primary, reference); - - assert.ok(result); - assert.ok(result.offsetSeconds > -32); - assert.ok(result.offsetSeconds < -31); -}); - -test('estimate subtitle timing offset ignores subtitle timelines that are already aligned', () => { - const starts = [1, 5, 9, 14, 20, 25, 31, 38]; - - const result = estimateSubtitleTimingOffset( - starts.map(cue), - starts.map((start) => cue(start + 0.04)), - ); - - assert.equal(result, null); -}); - -test('estimate subtitle timing offset rejects weak timeline matches', () => { - const primary = [10, 20, 30, 40, 50, 60, 70, 80].map(cue); - const reference = [1, 2, 3, 4, 5, 6, 7, 8].map(cue); - - const result = estimateSubtitleTimingOffset(primary, reference); - - assert.equal(result, null); -}); diff --git a/src/core/services/subtitle-timing-offset.ts b/src/core/services/subtitle-timing-offset.ts deleted file mode 100644 index e52ec79d..00000000 --- a/src/core/services/subtitle-timing-offset.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { SubtitleCue } from './subtitle-cue-parser'; - -export type SubtitleTimingOffsetResult = { - offsetSeconds: number; - matchCount: number; - meanErrorSeconds: number; - maxErrorSeconds: number; -}; - -export type SubtitleTimingOffsetOptions = { - maxCueCount?: number; - maxOffsetSeconds?: number; - matchThresholdSeconds?: number; - maxMeanErrorSeconds?: number; - minMatchCount?: number; - minMatchRatio?: number; - minUsefulOffsetSeconds?: number; -}; - -type OffsetScore = SubtitleTimingOffsetResult; - -const DEFAULT_MAX_CUE_COUNT = 60; -const DEFAULT_MAX_OFFSET_SECONDS = 180; -const DEFAULT_MATCH_THRESHOLD_SECONDS = 1; -const DEFAULT_MAX_MEAN_ERROR_SECONDS = 0.75; -const DEFAULT_MIN_MATCH_COUNT = 8; -const DEFAULT_MIN_MATCH_RATIO = 0.25; -const DEFAULT_MIN_USEFUL_OFFSET_SECONDS = 0.25; - -function normalizeCueStarts(cues: SubtitleCue[], maxCueCount: number): number[] { - const starts = cues - .map((cue) => cue.startTime) - .filter((start) => Number.isFinite(start) && start >= 0) - .sort((a, b) => a - b); - const deduped: number[] = []; - for (const start of starts) { - const previous = deduped[deduped.length - 1]; - if (previous === undefined || Math.abs(start - previous) > 0.05) { - deduped.push(start); - } - if (deduped.length >= maxCueCount) { - break; - } - } - return deduped; -} - -function roundToMillis(value: number): number { - return Math.round(value * 1000) / 1000; -} - -function scoreOffset( - primaryStarts: number[], - referenceStarts: number[], - offsetSeconds: number, - matchThresholdSeconds: number, -): OffsetScore { - let primaryIndex = 0; - let referenceIndex = 0; - let matchCount = 0; - let totalErrorSeconds = 0; - let maxErrorSeconds = 0; - - while (primaryIndex < primaryStarts.length && referenceIndex < referenceStarts.length) { - const shiftedPrimary = primaryStarts[primaryIndex]! + offsetSeconds; - const reference = referenceStarts[referenceIndex]!; - const errorSeconds = Math.abs(shiftedPrimary - reference); - if (errorSeconds <= matchThresholdSeconds) { - matchCount += 1; - totalErrorSeconds += errorSeconds; - maxErrorSeconds = Math.max(maxErrorSeconds, errorSeconds); - primaryIndex += 1; - referenceIndex += 1; - continue; - } - - if (shiftedPrimary < reference) { - primaryIndex += 1; - } else { - referenceIndex += 1; - } - } - - return { - offsetSeconds, - matchCount, - meanErrorSeconds: matchCount > 0 ? totalErrorSeconds / matchCount : Number.POSITIVE_INFINITY, - maxErrorSeconds, - }; -} - -function isBetterScore(next: OffsetScore, current: OffsetScore | null): boolean { - if (current === null) return true; - if (next.matchCount !== current.matchCount) return next.matchCount > current.matchCount; - if (next.meanErrorSeconds !== current.meanErrorSeconds) { - return next.meanErrorSeconds < current.meanErrorSeconds; - } - return Math.abs(next.offsetSeconds) < Math.abs(current.offsetSeconds); -} - -export function estimateSubtitleTimingOffset( - primaryCues: SubtitleCue[], - referenceCues: SubtitleCue[], - options: SubtitleTimingOffsetOptions = {}, -): SubtitleTimingOffsetResult | null { - const maxCueCount = options.maxCueCount ?? DEFAULT_MAX_CUE_COUNT; - const maxOffsetSeconds = options.maxOffsetSeconds ?? DEFAULT_MAX_OFFSET_SECONDS; - const matchThresholdSeconds = options.matchThresholdSeconds ?? DEFAULT_MATCH_THRESHOLD_SECONDS; - const maxMeanErrorSeconds = options.maxMeanErrorSeconds ?? DEFAULT_MAX_MEAN_ERROR_SECONDS; - const minMatchCount = options.minMatchCount ?? DEFAULT_MIN_MATCH_COUNT; - const minMatchRatio = options.minMatchRatio ?? DEFAULT_MIN_MATCH_RATIO; - const minUsefulOffsetSeconds = - options.minUsefulOffsetSeconds ?? DEFAULT_MIN_USEFUL_OFFSET_SECONDS; - - const primaryStarts = normalizeCueStarts(primaryCues, maxCueCount); - const referenceStarts = normalizeCueStarts(referenceCues, maxCueCount); - const comparableCueCount = Math.min(primaryStarts.length, referenceStarts.length); - if (comparableCueCount < minMatchCount) { - return null; - } - - const candidates = new Set(); - for (const primaryStart of primaryStarts) { - for (const referenceStart of referenceStarts) { - const offsetSeconds = roundToMillis(referenceStart - primaryStart); - if (Math.abs(offsetSeconds) <= maxOffsetSeconds) { - candidates.add(offsetSeconds); - } - } - } - - let best: OffsetScore | null = null; - for (const offsetSeconds of candidates) { - if (Math.abs(offsetSeconds) < minUsefulOffsetSeconds) { - continue; - } - const score = scoreOffset(primaryStarts, referenceStarts, offsetSeconds, matchThresholdSeconds); - if (score.matchCount < minMatchCount) { - continue; - } - if (score.matchCount / comparableCueCount < minMatchRatio) { - continue; - } - if (score.meanErrorSeconds > maxMeanErrorSeconds) { - continue; - } - if (isBetterScore(score, best)) { - best = score; - } - } - - return best; -} diff --git a/src/core/services/tokenizer.test.ts b/src/core/services/tokenizer.test.ts index 17b770e8..b8a7f0d2 100644 --- a/src/core/services/tokenizer.test.ts +++ b/src/core/services/tokenizer.test.ts @@ -84,6 +84,17 @@ function createDeferred() { }; } +test('tokenizeSubtitle keeps the blank line separating simultaneous cues', async () => { + // The tokenized payload's text drives display; folding the cue boundary would merge + // two speakers back onto one line the moment tokenization upgrades the plain emit. + const result = await tokenizeSubtitle( + '\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee', + makeDeps({ getYomitanExt: () => null }), + ); + + assert.equal(result.text, '\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee'); +}); + test('tokenizeSubtitle splits same-line grammar endings before applying annotations', async () => { const result = await tokenizeSubtitle( '猫です', @@ -1682,6 +1693,12 @@ test('tokenizeSubtitle normalizes newlines before Yomitan parse request', async assert.equal(result.tokens, null); }); +test('tokenizeSubtitle preserves CRLF boundaries between simultaneous cues', async () => { + const result = await tokenizeSubtitle('a\r\n\r\nb', makeDeps()); + + assert.deepEqual(result, { text: 'a\n\nb', tokens: null }); +}); + test('tokenizeSubtitle collapses zero-width separators before Yomitan parse request', async () => { let parseInput = ''; const result = await tokenizeSubtitle( diff --git a/src/core/services/tokenizer.ts b/src/core/services/tokenizer.ts index e1bdbd88..d5cd5e12 100644 --- a/src/core/services/tokenizer.ts +++ b/src/core/services/tokenizer.ts @@ -887,7 +887,15 @@ export async function tokenizeSubtitle( text: string, deps: TokenizerServiceDeps, ): Promise { - const displayText = normalizePlainSubtitleText(text); + // Normalize per cue group: the blank line separating simultaneous cues is display + // structure the payload text must keep, or the tokenized upgrade re-merges lines the + // provisional plain emit already showed apart. + const displayText = text + .replace(/\r\n/g, '\n') + .split(/\n{2,}/) + .map((part) => normalizePlainSubtitleText(part)) + .filter(Boolean) + .join('\n\n'); // ASS decoding already happened upstream (cue parser for files, mpv for live text), so // all this drops is whitespace -- but a whitespace-only line still normalizes to empty. diff --git a/src/core/services/youtube/timedtext.test.ts b/src/core/services/youtube/timedtext.test.ts index 1f543cd9..1320bdad 100644 --- a/src/core/services/youtube/timedtext.test.ts +++ b/src/core/services/youtube/timedtext.test.ts @@ -39,6 +39,118 @@ test('convertYoutubeTimedTextToVtt does not swallow text after zero-length overl ); }); +test('convertYoutubeTimedTextToVtt extends rolling captions to the next window event', () => { + // Real-world shape of YouTube's sentence-level auto captions: window-append + // filler rows (a="1", sometimes without d) mark the display timeline, while + // long text rows carry a placeholder d="3000" far shorter than the speech. + const result = convertYoutubeTimedTextToVtt( + [ + '', + '

\n

', + '

ありがとうって言えないよね。こんなんじゃ。

', + '

\n

', + '

私だったら無理だよ。

', + '
', + ].join('\n'), + ); + + assert.equal( + result, + [ + 'WEBVTT', + '', + '00:01:38.560 --> 00:01:46.950', + 'ありがとうって言えないよね。こんなんじゃ。', + '', + '00:01:46.960 --> 00:01:50.759', + '私だったら無理だよ。', + '', + ].join('\n'), + ); +}); + +test('convertYoutubeTimedTextToVtt pages oversized two-row rolling captions', () => { + const text = + 'あの西に結構こう山田がスーパーアプローチしてるんだけど西気づかないからちょっとこっちも気づかない感じでこう接してあげようかなて思ってんだけどあの唇巻き込んじゃうしあの思ってることも全部縁に出ちゃって自分であちゃったって言っちゃうタイプなんで結構なんかこうドライなんだけどそこがおもろいよねみたいな'; + const result = convertYoutubeTimedTextToVtt( + [ + '', + '', + '', + '', + '', + '', + '', + `

${text}

`, + '

\n

', + '', + '
', + ].join('\n'), + ); + + const cues = result + .trim() + .split(/\n\n/) + .filter((block) => block.includes('-->')); + const cueText = cues.map((cue) => cue.split('\n').slice(1).join('\n')); + + assert.equal(cues.length, 2); + assert.deepEqual( + cues.map((cue) => cue.split('\n')[0]), + ['00:01:00.440 --> 00:01:07.064', '00:01:07.064 --> 00:01:12.695'], + ); + assert.ok(cueText.every((page) => [...page].length <= 80)); + assert.equal(cueText.join(''), text); +}); + +test('convertYoutubeTimedTextToVtt leaves pop-on captions intact', () => { + const result = convertYoutubeTimedTextToVtt( + [ + '', + '', + '', + '', + '', + '', + '', + '

abcdefghijklmnopqrst

', + '', + '
', + ].join('\n'), + ); + + assert.equal( + result, + ['WEBVTT', '', '00:00:01.000 --> 00:00:04.000', 'abcdefghijklmnopqrst', ''].join('\n'), + ); +}); + +test('convertYoutubeTimedTextToVtt keeps explicit 3000ms sound-cue durations in rolling documents', () => { + const result = convertYoutubeTimedTextToVtt( + [ + '', + '

[音楽]

', + '

\n

', + '

じゃあ、君からお願いします。

', + '
', + ].join('\n'), + ); + + assert.equal( + result, + [ + 'WEBVTT', + '', + '00:00:20.305 --> 00:00:23.305', + '[音楽]', + '', + '00:00:26.279 --> 00:00:29.279', + 'じゃあ、君からお願いします。', + '', + ].join('\n'), + ); +}); + test('normalizeYoutubeAutoVtt strips cumulative rolling-caption prefixes', () => { const result = normalizeYoutubeAutoVtt( [ diff --git a/src/core/services/youtube/timedtext.ts b/src/core/services/youtube/timedtext.ts index fe3aaf47..c0a88020 100644 --- a/src/core/services/youtube/timedtext.ts +++ b/src/core/services/youtube/timedtext.ts @@ -2,9 +2,31 @@ interface YoutubeTimedTextRow { startMs: number; durationMs: number; text: string; + isGenerated: boolean; + rollingWindow: YoutubeRollingWindow | null; +} + +interface YoutubeRollingWindow { + rowCount: number; + columnCount: number; +} + +interface YoutubeTimedTextWindowDefinitions { + rollingStyleIds: Set; + positions: Map; + windows: Map; +} + +interface YoutubeTimedTextDocument { + rows: YoutubeTimedTextRow[]; + // Start times of every

event, including empty window-append fillers. + // Rolling speech rows with a 3000ms placeholder display until the next event. + eventStartsMs: number[]; + hasRollingWindowEvents: boolean; } const YOUTUBE_TIMEDTEXT_EXTENSIONS = new Set(['srv1', 'srv2', 'srv3', 'ytsrv3']); +const YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS = 3_000; function decodeNumericEntity(match: string, codePoint: number): string { if ( @@ -39,27 +61,129 @@ function parseAttributeMap(raw: string): Map { return attrs; } -function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] { +function parsePositiveInteger(value: string | undefined): number | null { + if (value === undefined) { + return null; + } + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; +} + +function extractYoutubeTimedTextWindowDefinitions(xml: string): YoutubeTimedTextWindowDefinitions { + const rollingStyleIds = new Set(); + for (const match of xml.matchAll(/]*)\/?\s*>/g)) { + const attrs = parseAttributeMap(match[1] ?? ''); + const id = attrs.get('id'); + if (id !== undefined && attrs.get('mh') === '2') { + rollingStyleIds.add(id); + } + } + + const positions = new Map(); + for (const match of xml.matchAll(/]*)\/?\s*>/g)) { + const attrs = parseAttributeMap(match[1] ?? ''); + const id = attrs.get('id'); + const rowCount = parsePositiveInteger(attrs.get('rc')); + const columnCount = parsePositiveInteger(attrs.get('cc')); + if (id !== undefined && rowCount !== null && columnCount !== null) { + positions.set(id, { rowCount, columnCount }); + } + } + + const windows = new Map(); + for (const match of xml.matchAll(/]*)\/?\s*>/g)) { + const attrs = parseAttributeMap(match[1] ?? ''); + const id = attrs.get('id'); + const styleId = attrs.get('ws'); + const positionId = attrs.get('wp'); + const position = positionId === undefined ? undefined : positions.get(positionId); + if ( + id !== undefined && + styleId !== undefined && + rollingStyleIds.has(styleId) && + position !== undefined + ) { + windows.set(id, position); + } + } + + return { rollingStyleIds, positions, windows }; +} + +function resolveRollingWindow( + attrs: Map, + definitions: YoutubeTimedTextWindowDefinitions, +): YoutubeRollingWindow | null { + const windowId = attrs.get('w'); + if (windowId !== undefined) { + return definitions.windows.get(windowId) ?? null; + } + + const styleId = attrs.get('ws'); + const positionId = attrs.get('wp'); + if ( + styleId === undefined || + positionId === undefined || + !definitions.rollingStyleIds.has(styleId) + ) { + return null; + } + return definitions.positions.get(positionId) ?? null; +} + +function extractYoutubeTimedTextDocument(xml: string): YoutubeTimedTextDocument { const rows: YoutubeTimedTextRow[] = []; + const eventStartsMs: number[] = []; + let hasRollingWindowEvents = false; + const windowDefinitions = extractYoutubeTimedTextWindowDefinitions(xml); for (const match of xml.matchAll(/]*)>([\s\S]*?)<\/p>/g)) { const attrs = parseAttributeMap(match[1] ?? ''); const startMs = Number(attrs.get('t')); + if (!Number.isFinite(startMs)) { + continue; + } + eventStartsMs.push(startMs); + if (attrs.get('a') === '1') { + hasRollingWindowEvents = true; + } + const durationMs = Number(attrs.get('d')); - if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) { + if (!Number.isFinite(durationMs)) { continue; } - const inner = (match[2] ?? '').replace(//gi, '\n').replace(/<[^>]+>/g, ''); + const rawInner = match[2] ?? ''; + const inner = rawInner.replace(//gi, '\n').replace(/<[^>]+>/g, ''); const text = decodeHtmlEntities(inner).trim(); if (!text) { continue; } - rows.push({ startMs, durationMs, text }); + rows.push({ + startMs, + durationMs, + text, + isGenerated: / a - b); + return { rows, eventStartsMs, hasRollingWindowEvents }; +} + +function findNextEventStartMs(eventStartsMs: number[], afterMs: number): number | undefined { + for (const startMs of eventStartsMs) { + if (startMs > afterMs) { + return startMs; + } + } + return undefined; +} + +function isGeneratedRollingCue(row: YoutubeTimedTextRow, hasRollingWindowEvents: boolean): boolean { + return row.isGenerated && (row.rollingWindow !== null || hasRollingWindowEvents); } function formatVttTimestamp(ms: number): string { @@ -71,6 +195,79 @@ function formatVttTimestamp(ms: number): string { return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`; } +const ROLLING_PAGE_BREAK_PATTERN = /[\s、。!?!?]/u; + +// VTT cannot carry SRV3's row and column limits. Page only roll-up windows so +// the overlay keeps their bounded presentation without changing authored cues. +function splitRollingCaptionIntoPages(text: string, rollingWindow: YoutubeRollingWindow): string[] { + const pageCapacity = rollingWindow.rowCount * rollingWindow.columnCount; + const characters = [...text]; + if ( + !Number.isSafeInteger(pageCapacity) || + pageCapacity <= 0 || + characters.length <= pageCapacity + ) { + return [text]; + } + + const pages: string[] = []; + let pageStart = 0; + while (pageStart < characters.length) { + let pageEnd = Math.min(pageStart + pageCapacity, characters.length); + if (pageEnd < characters.length) { + const earliestNaturalBreak = pageStart + Math.ceil(pageCapacity * 0.6); + for (let index = pageEnd - 1; index >= earliestNaturalBreak; index -= 1) { + if (ROLLING_PAGE_BREAK_PATTERN.test(characters[index]!)) { + pageEnd = index + 1; + break; + } + } + } + pages.push(characters.slice(pageStart, pageEnd).join('')); + pageStart = pageEnd; + } + return pages; +} + +interface TimedCaptionPage { + startMs: number; + endMs: number; + text: string; +} + +function timeCaptionPages(input: { + text: string; + pages: string[]; + startMs: number; + endMs: number; +}): TimedCaptionPage[] { + const durationMs = input.endMs - input.startMs; + if (input.pages.length === 1 || durationMs < input.pages.length) { + return [{ startMs: input.startMs, endMs: input.endMs, text: input.text }]; + } + + const totalCharacters = [...input.text].length; + const timedPages: TimedCaptionPage[] = []; + let consumedCharacters = 0; + let pageStartMs = input.startMs; + // Automatic captions often omit span offsets, so distribute the known cue + // duration by page length while guaranteeing every page at least one ms. + for (let index = 0; index < input.pages.length; index += 1) { + const page = input.pages[index]!; + consumedCharacters += [...page].length; + const remainingPages = input.pages.length - index - 1; + const proportionalEndMs = + input.startMs + Math.round((durationMs * consumedCharacters) / totalCharacters); + const pageEndMs = + remainingPages === 0 + ? input.endMs + : Math.min(Math.max(proportionalEndMs, pageStartMs + 1), input.endMs - remainingPages); + timedPages.push({ startMs: pageStartMs, endMs: pageEndMs, text: page }); + pageStartMs = pageEndMs; + } + return timedPages; +} + export function isYoutubeTimedTextExtension(value: string | undefined): boolean { if (!value) { return false; @@ -79,7 +276,7 @@ export function isYoutubeTimedTextExtension(value: string | undefined): boolean } export function convertYoutubeTimedTextToVtt(xml: string): string { - const rows = extractYoutubeTimedTextRows(xml); + const { rows, eventStartsMs, hasRollingWindowEvents } = extractYoutubeTimedTextDocument(xml); if (rows.length === 0) { return 'WEBVTT\n'; } @@ -90,10 +287,19 @@ export function convertYoutubeTimedTextToVtt(xml: string): string { const row = rows[index]!; const nextRow = rows[index + 1]; const unclampedEnd = row.startMs + row.durationMs; + // YouTube uses exactly 3000ms as a placeholder for generated rolling speech. + // Plain-text cues can explicitly use the same duration and must keep it. + const nextEventStart = + isGeneratedRollingCue(row, hasRollingWindowEvents) && + row.durationMs === YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS + ? findNextEventStartMs(eventStartsMs, row.startMs) + : undefined; const clampedEnd = - nextRow && unclampedEnd > nextRow.startMs - ? Math.max(row.startMs, nextRow.startMs - 1) - : unclampedEnd; + nextEventStart !== undefined + ? nextEventStart + : nextRow && unclampedEnd > nextRow.startMs + ? Math.max(row.startMs, nextRow.startMs - 1) + : unclampedEnd; if (clampedEnd <= row.startMs) { continue; } @@ -106,9 +312,19 @@ export function convertYoutubeTimedTextToVtt(xml: string): string { if (!text) { continue; } - blocks.push( - `${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${text}`, - ); + const pages = row.rollingWindow + ? splitRollingCaptionIntoPages(text, row.rollingWindow) + : [text]; + for (const page of timeCaptionPages({ + text, + pages, + startMs: row.startMs, + endMs: clampedEnd, + })) { + blocks.push( + `${formatVttTimestamp(page.startMs)} --> ${formatVttTimestamp(page.endMs)}\n${page.text}`, + ); + } } return `WEBVTT\n\n${blocks.join('\n\n')}\n`; diff --git a/src/main.ts b/src/main.ts index 7655d20d..80dc1d77 100644 --- a/src/main.ts +++ b/src/main.ts @@ -235,6 +235,7 @@ import { createCycleSecondarySubModeRuntimeHandler, } from './main/runtime/domains/mpv'; import { buildSubtitleTrackDiagnostics } from './main/runtime/mpv-track-diagnostics'; +import { resolveCanonicalPrimarySubtitle } from './main/runtime/primary-subtitle-text'; import { createBuildCopyCurrentSubtitleMainDepsHandler, createBuildHandleMineSentenceDigitMainDepsHandler, @@ -301,7 +302,6 @@ import { listJellyfinItemsRuntime, listJellyfinLibrariesRuntime, listJellyfinSubtitleTracksRuntime, - loadJellyfinSubtitleDelay, loadSubtitlePosition as loadSubtitlePositionCore, loadYomitanExtension as loadYomitanExtensionCore, markLastCardAsAudioCard as markLastCardAsAudioCardCore, @@ -311,9 +311,9 @@ import { promoteSettingsWindowAboveOverlay, registerGlobalShortcuts as registerGlobalShortcutsCore, replayCurrentSubtitleRuntime, + resolveSanitizedSubtitleSeekCommand, resolveJellyfinPlaybackPlanRuntime, runStartupBootstrapRuntime, - saveJellyfinSubtitleDelay, saveSubtitlePosition as saveSubtitlePositionCore, clearYomitanParserCachesForWindow, getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore, @@ -527,6 +527,7 @@ import { createRefreshSubtitlePrefetchFromActiveTrackHandler, createResolveActiveSubtitleSidebarSourceHandler, } from './main/runtime/subtitle-prefetch-runtime'; +import { createSecondarySubtitleTrackController } from './main/runtime/secondary-subtitle-track'; import { createCreateAnilistSetupWindowHandler, createCreateConfigSettingsWindowHandler, @@ -585,9 +586,10 @@ import { import { buildSubtitleSidebarSourceKey } from './main/runtime/subtitle-prefetch-source'; import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init'; import { + createCachedInternalSubtitleTrackExtractor, loadSubtitleSourceText, - extractInternalSubtitleTrackToTempFile, } from './main/runtime/internal-subtitle-extraction'; +import { createRemoteMediaPathDetector } from './main/runtime/network-media-path'; import { applyCharacterDictionarySelection } from './main/character-dictionary-selection'; import { getSubsyncConfig } from './subsync/utils'; @@ -673,7 +675,6 @@ function spawnManagedMpvProcess(args: string[]): ReturnType { } let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null; -let activeJellyfinSubtitleDelayKey: { itemId: string; streamIndex: number } | null = null; let jellyfinRemoteLastProgressAtMs = 0; let jellyfinMpvAutoLaunchInFlight: Promise | null = null; let backgroundWarmupsStarted = false; @@ -1806,10 +1807,42 @@ async function openYoutubeTrackPickerFromPlayback(): Promise { let appTray: Tray | null = null; let tokenizeSubtitleDeferred: ((text: string) => Promise) | null = null; function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData { + const canonical = resolveCanonicalPrimarySubtitle({ + liveText: payload.text, + currentTimeSec: Number(appState.mpvClient?.currentTimePos), + cues: appState.activeParsedSubtitleCues, + }); return { ...payload, - startTime: appState.mpvClient?.currentSubStart ?? null, - endTime: appState.mpvClient?.currentSubEnd ?? null, + startTime: canonical?.startTime ?? appState.mpvClient?.currentSubStart ?? null, + endTime: canonical?.endTime ?? appState.mpvClient?.currentSubEnd ?? null, + }; +} + +function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | null { + const canonical = resolveCanonicalPrimarySubtitle({ + liveText: appState.mpvClient?.currentSubText ?? '', + currentTimeSec: Number(appState.mpvClient?.currentTimePos), + cues: appState.activeParsedSubtitleCues, + }); + // Same validity bar as the live capture path: an unusable canonical span must fall + // back rather than hand mining an empty line or an inverted range. + const canonicalText = canonical?.text.trim(); + if ( + !canonical || + !canonicalText || + !Number.isFinite(canonical.startTime) || + !Number.isFinite(canonical.endTime) || + canonical.endTime <= canonical.startTime + ) { + return captureLiveSubtitleMiningContext(appState.mpvClient); + } + return { + source: 'overlay', + text: canonicalText, + startTime: canonical.startTime, + endTime: canonical.endTime, + capturedAtMs: Date.now(), }; } function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void { @@ -1924,6 +1957,31 @@ let linuxVisibleOverlayOwnerBindingKey: string | null = null; let linuxVisibleOverlayWindowModeSwitchToken = 0; let subtitleSidebarRequestedOpen = false; const SEEK_THRESHOLD_SECONDS = 3; +const EXPLICIT_SEEK_INTENT_TTL_MS = 2000; +let explicitSeekIntentExpiresAtMs = 0; + +function isExplicitMpvSeekCommand(command: readonly (string | number)[]): boolean { + return command[0] === 'seek' || command[0] === 'sub-seek'; +} + +function sendRendererMpvCommand(rawCommand: (string | number)[]): void { + const command = + resolveSanitizedSubtitleSeekCommand( + rawCommand, + appState.activeParsedSubtitleCues, + appState.mpvClient?.currentTimePos ?? Number.NaN, + ) ?? rawCommand; + if (isExplicitMpvSeekCommand(command)) { + explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS; + } + sendMpvCommandRuntime(appState.mpvClient, command); +} + +function consumeExplicitSeekIntent(): boolean { + const pending = explicitSeekIntentExpiresAtMs >= Date.now(); + explicitSeekIntentExpiresAtMs = 0; + return pending; +} const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({ getCurrentMediaPath: () => appState.currentMediaPath, @@ -1943,7 +2001,7 @@ const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({ getLastObservedTimePos: () => lastObservedTimePos, getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), emitSecondarySubtitle: (text) => { - overlayManager.broadcastToOverlayWindows('secondary-subtitle:set', text); + secondarySubtitleTrackController.handleLiveText(text); }, initSubtitlePrefetch: (sourcePath, currentTimePos, sourceKey) => subtitlePrefetchInitController.initSubtitlePrefetch(sourcePath, currentTimePos, sourceKey), @@ -1994,13 +2052,33 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({ } }, }); +const cachedInternalSubtitleTrackExtractor = createCachedInternalSubtitleTrackExtractor(); +const detectRemoteMediaPath = createRemoteMediaPathDetector(); const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler({ getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg', extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) => - extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track), + cachedInternalSubtitleTrackExtractor.extract(ffmpegPath, videoPath, track), logDebug: (message) => logger.debug(message), }); +const secondarySubtitleTrackController = createSecondarySubtitleTrackController({ + getMpvClient: () => appState.mpvClient, + getCurrentTimePos: () => appState.mpvClient?.currentTimePos ?? lastObservedTimePos, + resolveSubtitleSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input), + loadSubtitleSourceText, + parseSubtitleCues: (content, filename) => parseSubtitleCues(content, filename), + setCurrentSecondaryText: (text) => { + if (appState.mpvClient) { + appState.mpvClient.currentSecondarySubText = text; + } + }, + broadcastSecondaryText: (text) => { + overlayManager.broadcastToOverlayWindows('secondary-subtitle:set', text); + }, + logDebug: (message) => logger.debug(message), + logWarn: (message, error) => logger.warn(message, error), +}); + const refreshSubtitlePrefetchFromActiveTrackHandler = createRefreshSubtitlePrefetchFromActiveTrackHandler({ getMpvClient: () => appState.mpvClient, @@ -2008,8 +2086,8 @@ const refreshSubtitlePrefetchFromActiveTrackHandler = // 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), + shouldKeepExistingCuesOnMissingSource: async (videoPath) => + isYoutubeMediaPath(videoPath) || (await detectRemoteMediaPath(videoPath)), subtitlePrefetchInitController, resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input), logDebug: (message) => logger.debug(message), @@ -2401,7 +2479,6 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime characterDictionaryAutoSyncRuntime.getCurrentMediaId(), + onIndexReady: () => refreshCurrentSubtitleAnnotations(), + onIndexReadyError: (error) => + logger.warn( + 'Failed to refresh subtitle annotations after character portrait index became ready.', + error, + ), }); // Lets the Yomitan scan runtime skip name lookups at positions where no @@ -3019,23 +3102,6 @@ const { wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), cacheSubtitleTrack: (track) => jellyfinSubtitleCacheIo.cacheSubtitleTrack(track), cleanupCachedSubtitles: (dirs) => jellyfinSubtitleCacheIo.cleanupCachedSubtitles(dirs), - getSavedSubtitleDelay: (itemId, streamIndex) => - loadJellyfinSubtitleDelay({ - filePath: JELLYFIN_SUBTITLE_DELAYS_PATH, - itemId, - streamIndex, - }), - setActiveSubtitleDelayKey: (key) => { - activeJellyfinSubtitleDelayKey = key; - }, - loadSubtitleSourceText, - saveSubtitleDelay: (itemId, streamIndex, delaySeconds) => - saveJellyfinSubtitleDelay({ - filePath: JELLYFIN_SUBTITLE_DELAYS_PATH, - itemId, - streamIndex, - delaySeconds, - }), initSubtitlePrefetch: (sourcePath) => subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath), logDebug: (message, error) => { @@ -3101,7 +3167,6 @@ const { getActivePlayback: () => activeJellyfinRemotePlayback, clearActivePlayback: () => { activeJellyfinRemotePlayback = null; - activeJellyfinSubtitleDelayKey = null; }, getSession: () => appState.jellyfinRemoteSession, getNow: () => Date.now(), @@ -3878,6 +3943,7 @@ const { appState.yomitanSettingsWindow = null; }, stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(), + cleanupInternalSubtitleTrackCache: () => cachedInternalSubtitleTrackExtractor.clear(), cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(), cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(), cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(), @@ -3995,7 +4061,7 @@ const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => { ensureImmersionTrackerStarted(); appState.immersionTracker?.recordCardsMined(count, noteIds); }; -const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => { +function refreshCurrentSubtitleAnnotations(): void { const hasCurrentSubtitle = appState.currentSubText.trim().length > 0; if (hasCurrentSubtitle) { subtitlePrefetchService?.pause(); @@ -4006,7 +4072,7 @@ const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => { // Idle controller: no settle is coming to release the pause above. subtitlePrefetchService?.resume(); } -}; +} let hasAttemptedImmersionTrackerStartup = false; const ensureImmersionTrackerStarted = (): void => { if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) { @@ -4383,6 +4449,7 @@ const { onMpvConnected: () => { maybeStartOverlayLoadingOsd(); flushQueuedMpvOsdNotifications(); + secondarySubtitleTrackController.scheduleRefresh(0); if (appState.sessionBindingsInitialized) { sendMpvCommandRuntime(appState.mpvClient, [ 'script-message', @@ -4401,6 +4468,9 @@ const { broadcastToOverlayWindows: (channel, payload) => { overlayManager.broadcastToOverlayWindows(channel, payload); }, + onSecondarySubtitleChange: (text) => { + secondarySubtitleTrackController.handleLiveText(text); + }, getImmediateSubtitlePayload: (text) => subtitleProcessingController.consumeCachedSubtitle(text), emitImmediateSubtitle: (payload) => { emitSubtitlePayload(payload); @@ -4434,6 +4504,8 @@ const { appState.activeParsedSubtitleMediaPath, ); if ((normalizedPath || null) !== previousPath) { + cachedInternalSubtitleTrackExtractor.clear(); + secondarySubtitleTrackController.reset(); const resetSubtitlePayload = { text: '', tokens: null }; const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary; const frequencyOptions = { @@ -4451,7 +4523,6 @@ const { appState.activeParsedSubtitleSource = null; appState.activeParsedSubtitleMediaPath = null; } - activeJellyfinSubtitleDelayKey = null; overlayManager.broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload); subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); @@ -4468,6 +4539,7 @@ const { void youtubeMediaCachePlaybackRuntime.handleMediaPathChange(path); if (path) { ensureImmersionTrackerStarted(); + secondarySubtitleTrackController.scheduleRefresh(); void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack(); // Retry after a short delay because MPV can populate track-list after path. subtitlePrefetchRuntime.scheduleSubtitlePrefetchRefresh(500); @@ -4516,12 +4588,14 @@ const { reportJellyfinRemoteProgress: (forceImmediate) => { void reportJellyfinRemoteProgress(forceImmediate); }, + consumeExplicitSeek: () => consumeExplicitSeekIntent(), onTimePosUpdate: (time) => { const delta = time - lastObservedTimePos; if (subtitlePrefetchService && (delta > SEEK_THRESHOLD_SECONDS || delta < 0)) { subtitlePrefetchService.onSeek(time); } lastObservedTimePos = time; + secondarySubtitleTrackController.handleTimePos(time); }, onFullscreenChange: (fullscreen) => { cancelLinuxMpvFullscreenOverlayRefreshBurst = updateLinuxMpvFullscreenOverlayRefreshBurst( @@ -4549,6 +4623,13 @@ const { autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(); youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid); }, + onSecondarySubtitleTrackChange: () => { + secondarySubtitleTrackController.handleTrackChange(); + secondarySubtitleTrackController.scheduleRefresh(0); + }, + onSecondarySubtitleDelayChange: (delay) => { + secondarySubtitleTrackController.handleDelayChange(delay); + }, onSubtitleTrackListChange: (trackList) => { const diagnostics = buildSubtitleTrackDiagnostics( lastObservedPrimarySubtitleTrackId, @@ -4562,6 +4643,7 @@ const { logger.info('[mpv-subtitles] subtitle track list updated', diagnostics); } managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList); + secondarySubtitleTrackController.scheduleRefresh(0); autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(); youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList); }, @@ -5014,9 +5096,7 @@ function initializeOverlayRuntime(): void { overlayModalRuntime.primeModalWindow(); } appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); - appState.ankiIntegration?.setKnownWordCacheUpdatedCallback( - refreshCurrentSubtitleAfterKnownWordUpdate, - ); + appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations); appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext); syncOverlayMpvSubtitleSuppression(); } @@ -5231,6 +5311,7 @@ const markLastCardAsAudioCardHandler = createMarkLastCardAsAudioCardHandler( const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDepsHandler({ getAnkiIntegration: () => appState.ankiIntegration, getMpvClient: () => appState.mpvClient, + getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(), showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text), mineSentenceCardCore, recordCardsMined: (count, noteIds) => { @@ -5413,8 +5494,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text), replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient), playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient), - sendMpvCommand: (rawCommand: (string | number)[]) => - sendMpvCommandRuntime(appState.mpvClient, rawCommand), + sendMpvCommand: (rawCommand: (string | number)[]) => sendRendererMpvCommand(rawCommand), getMpvClient: () => appState.mpvClient, isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null, @@ -5544,9 +5624,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ // live mpv sub timings at lookup time so media generation clips the mined line even // when extraction finishes long after playback has moved on. recordSubtitleMiningContext: (context) => - recordSubtitleMiningContext( - context ?? captureLiveSubtitleMiningContext(appState.mpvClient), - ), + recordSubtitleMiningContext(context ?? captureCurrentPrimarySubtitleMiningContext()), quitApp: () => requestAppQuit(), toggleVisibleOverlay: () => toggleVisibleOverlay(), tokenizeCurrentSubtitle: async () => { @@ -5810,7 +5888,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ appState.ankiIntegration = integration; appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); appState.ankiIntegration?.setKnownWordCacheUpdatedCallback( - refreshCurrentSubtitleAfterKnownWordUpdate, + refreshCurrentSubtitleAnnotations, ); appState.ankiIntegration?.setSubtitleMiningContextConsumer( consumePendingSubtitleMiningContext, @@ -5824,6 +5902,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ showDesktopNotification, showOverlayNotification: (payload) => overlayNotificationsRuntime.showOverlayNotification(payload), + dismissOverlayNotification: (id) => + overlayNotificationsRuntime.dismissOverlayNotification(id), createFieldGroupingCallback: () => createFieldGroupingCallback(), broadcastRuntimeOptionsChanged: () => overlayVisibilityComposer.broadcastRuntimeOptionsChanged(), @@ -6314,6 +6394,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = showDesktopNotification, showOverlayNotification: (payload) => overlayNotificationsRuntime.showOverlayNotification(payload), + dismissOverlayNotification: (id) => + overlayNotificationsRuntime.dismissOverlayNotification(id), createFieldGroupingCallback: () => createFieldGroupingCallback(), getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), getCachedMediaPath: (currentVideoPath, kind) => diff --git a/src/main/character-dictionary-runtime.ts b/src/main/character-dictionary-runtime.ts index c362d80b..1186775b 100644 --- a/src/main/character-dictionary-runtime.ts +++ b/src/main/character-dictionary-runtime.ts @@ -450,7 +450,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar } const nameSplitTokenizerAvailable = isNameSplitTokenizerAvailable(); - const resolvedNameSplits = nameSplitTokenizerAvailable + const nameSplitResolution = nameSplitTokenizerAvailable ? await resolveJapaneseNameSplits( characters, deps.tokenizeJapaneseName!, @@ -466,8 +466,8 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar }, ) : undefined; - const nameSplitSource = - resolvedNameSplits && resolvedNameSplits.size > 0 ? 'mecab' : 'heuristic'; + const resolvedNameSplits = nameSplitResolution?.splits; + const nameSplitSource = nameSplitResolution?.kind === 'complete' ? 'mecab' : 'heuristic'; progress?.onGenerateProgress?.({ mediaId, diff --git a/src/main/character-dictionary-runtime/image-lookup.test.ts b/src/main/character-dictionary-runtime/image-lookup.test.ts index aadaf39a..c5b84f75 100644 --- a/src/main/character-dictionary-runtime/image-lookup.test.ts +++ b/src/main/character-dictionary-runtime/image-lookup.test.ts @@ -198,6 +198,66 @@ test('createCharacterDictionaryImageLookup can scope duplicate names to the curr assert.equal(scoped.alt, 'Kazuma'); }); +test('createCharacterDictionaryImageLookup reports and retries a failed index-ready callback', async () => { + const outputDir = makeTempDir(); + const snapshot: CharacterDictionarySnapshot = { + formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION, + mediaId: 21858, + mediaTitle: 'Little Witch Academia', + entryCount: 1, + updatedAt: 1_700_000_000_000, + termEntries: [ + [ + 'ダイアナ', + 'だいあな', + 'name primary', + '', + 75, + [ + { + type: 'structured-content', + content: { + tag: 'img', + path: 'img/m21858-c81709.png', + alt: 'ダイアナ・キャベンディッシュ', + }, + }, + ], + 0, + '', + ], + ], + images: [{ path: 'img/m21858-c81709.png', dataBase64: PNG_1X1_BASE64 }], + }; + await writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot); + const callbackError = new Error('annotation refresh failed'); + const reportingError = new Error('error reporter failed'); + let readyCount = 0; + const reportedErrors: unknown[] = []; + const lookup = createCharacterDictionaryImageLookup({ + outputDir, + onIndexReady: () => { + readyCount += 1; + if (readyCount === 1) { + throw callbackError; + } + }, + onIndexReadyError: (error) => { + reportedErrors.push(error); + throw reportingError; + }, + }); + + assert.equal(lookup.get('ダイアナ', snapshot.mediaId), null); + await waitForRefresh(() => (reportedErrors.length === 1 ? true : null)); + assert.ok(lookup.get('ダイアナ', snapshot.mediaId)); + + assert.equal(readyCount, 2); + assert.deepEqual(reportedErrors, [callbackError]); + lookup.get('ダイアナ', snapshot.mediaId); + assert.equal(readyCount, 2); +}); + test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', async () => { const outputDir = makeTempDir(); const snapshot: CharacterDictionarySnapshot = { diff --git a/src/main/character-dictionary-runtime/image-lookup.ts b/src/main/character-dictionary-runtime/image-lookup.ts index d21e3020..e10e1e5c 100644 --- a/src/main/character-dictionary-runtime/image-lookup.ts +++ b/src/main/character-dictionary-runtime/image-lookup.ts @@ -218,6 +218,8 @@ export function createCharacterDictionaryImageLookup(deps: { userDataPath?: string; outputDir?: string; getCurrentMediaId?: () => number | null | undefined; + onIndexReady?: () => void; + onIndexReadyError?: (error: unknown) => void; }): { get: (term: string, mediaId?: number | null) => CharacterNameImage | null; invalidate: () => void; @@ -229,6 +231,24 @@ export function createCharacterDictionaryImageLookup(deps: { let index = new Map(); let indexByMediaId = new Map>(); let refreshInFlight = false; + let indexReadyDeliveryPending = false; + + function deliverIndexReadyIfPending(): void { + if (!indexReadyDeliveryPending || !deps.onIndexReady) { + return; + } + indexReadyDeliveryPending = false; + try { + deps.onIndexReady(); + } catch (error) { + indexReadyDeliveryPending = true; + try { + deps.onIndexReadyError?.(error); + } catch { + // Error reporting must not reject the detached index refresh task. + } + } + } // Rebuilding means re-reading every cached snapshot (potentially GBs of JSON), which used to run // synchronously inside a lookup and froze the whole app right after a snapshot changed. Lookups @@ -241,6 +261,7 @@ export function createCharacterDictionaryImageLookup(deps: { signature = ''; return; } + deliverIndexReadyIfPending(); const nextSignature = getSnapshotDirectorySignature(outputDir); if (nextSignature === signature || refreshInFlight) { return; @@ -262,6 +283,8 @@ export function createCharacterDictionaryImageLookup(deps: { index = nextIndex; indexByMediaId = nextIndexByMediaId; signature = nextSignature; + indexReadyDeliveryPending = deps.onIndexReady !== undefined; + deliverIndexReadyIfPending(); } finally { refreshInFlight = false; } diff --git a/src/main/character-dictionary-runtime/name-split-resolver.test.ts b/src/main/character-dictionary-runtime/name-split-resolver.test.ts index 84f70c69..9f442ecd 100644 --- a/src/main/character-dictionary-runtime/name-split-resolver.test.ts +++ b/src/main/character-dictionary-runtime/name-split-resolver.test.ts @@ -43,7 +43,8 @@ test('resolveJapaneseNameSplits splits a single-kanji surname via person-name PO }), ); - assert.deepEqual(splits.get('東紫乃'), { family: '東', given: '紫乃' }); + assert.equal(splits.kind, 'complete'); + assert.deepEqual(splits.splits.get('東紫乃'), { family: '東', given: '紫乃' }); }); test('resolveJapaneseNameSplits corrects a hint-length-misleading surname boundary', async () => { @@ -64,7 +65,8 @@ test('resolveJapaneseNameSplits corrects a hint-length-misleading surname bounda }), ); - assert.deepEqual(splits.get('渡辺真奈美'), { family: '渡辺', given: '真奈美' }); + assert.equal(splits.kind, 'complete'); + assert.deepEqual(splits.splits.get('渡辺真奈美'), { family: '渡辺', given: '真奈美' }); }); test('resolveJapaneseNameSplits falls back to hint readings when POS tags are generic', async () => { @@ -85,7 +87,8 @@ test('resolveJapaneseNameSplits falls back to hint readings when POS tags are ge }), ); - assert.deepEqual(splits.get('鈴木みゆ'), { family: '鈴木', given: 'みゆ' }); + assert.equal(splits.kind, 'complete'); + assert.deepEqual(splits.splits.get('鈴木みゆ'), { family: '鈴木', given: 'みゆ' }); }); test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the name', async () => { @@ -96,7 +99,8 @@ test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the }), ); - assert.equal(splits.size, 0); + assert.equal(splits.kind, 'complete'); + assert.equal(splits.splits.size, 0); }); test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', async () => { @@ -117,7 +121,8 @@ test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', asyn }), ); - assert.equal(splits.size, 0); + assert.equal(splits.kind, 'complete'); + assert.equal(splits.splits.size, 0); }); test('resolveJapaneseNameSplits survives tokenizer failures', async () => { @@ -130,7 +135,8 @@ test('resolveJapaneseNameSplits survives tokenizer failures', async () => { (message) => warnings.push(message), ); - assert.equal(splits.size, 0); + assert.equal(splits.kind, 'incomplete'); + assert.equal(splits.splits.size, 0); assert.equal(warnings.length, 1); assert.match(warnings[0]!, /mecab unavailable/); }); diff --git a/src/main/character-dictionary-runtime/name-split-resolver.ts b/src/main/character-dictionary-runtime/name-split-resolver.ts index a1acedda..399a3a79 100644 --- a/src/main/character-dictionary-runtime/name-split-resolver.ts +++ b/src/main/character-dictionary-runtime/name-split-resolver.ts @@ -7,6 +7,10 @@ import type { ResolvedNameSplit, } from './types'; +export type JapaneseNameSplitResolution = + | { kind: 'complete'; splits: Map } + | { kind: 'incomplete'; splits: Map }; + const NAME_SEPARATOR_PATTERN = /[\s ・・·•]/; function joinSurfaces(tokens: NameSplitToken[]): string { @@ -87,8 +91,9 @@ export async function resolveJapaneseNameSplits( tokenize: NameSplitTokenizer, logWarn?: (message: string) => void, onCharacterResolved?: (completed: number, total: number) => void, -): Promise> { +): Promise { const splits = new Map(); + let tokenizerFailed = false; let resolvedCharacters = 0; for (const character of characters) { const familyHintReading = buildReadingFromHint(character.lastNameHint?.trim() || ''); @@ -99,12 +104,17 @@ export async function resolveJapaneseNameSplits( try { tokens = await tokenize(name); } catch (err) { + tokenizerFailed = true; logWarn?.( `[dictionary] name split tokenization failed for "${name}": ${(err as Error).message}`, ); continue; } - if (!tokens || tokens.length < 2 || joinSurfaces(tokens) !== name) continue; + if (!tokens) { + tokenizerFailed = true; + continue; + } + if (tokens.length < 2 || joinSurfaces(tokens) !== name) continue; const splitIndex = splitIndexFromPersonNamePos(tokens) ?? splitIndexFromHintReadings(tokens, familyHintReading, givenHintReading); @@ -118,5 +128,5 @@ export async function resolveJapaneseNameSplits( resolvedCharacters += 1; onCharacterResolved?.(resolvedCharacters, characters.length); } - return splits; + return tokenizerFailed ? { kind: 'incomplete', splits } : { kind: 'complete', splits }; } diff --git a/src/main/character-dictionary-runtime/snapshot-refresh.test.ts b/src/main/character-dictionary-runtime/snapshot-refresh.test.ts index b1d34fcf..25d0ecbd 100644 --- a/src/main/character-dictionary-runtime/snapshot-refresh.test.ts +++ b/src/main/character-dictionary-runtime/snapshot-refresh.test.ts @@ -7,7 +7,7 @@ import test from 'node:test'; import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime'; import { getSnapshotPath, writeSnapshot } from './cache'; import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants'; -import type { CharacterDictionarySnapshot } from './types'; +import type { CharacterDictionarySnapshot, NameSplitTokenizer } from './types'; const GRAPHQL_URL = 'https://graphql.anilist.co'; const PNG_1X1 = Buffer.from( @@ -121,7 +121,12 @@ test('generateForCurrentMedia refreshes same-version snapshots missing images wh } }); -test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => { +async function runNameSplitRefreshScenario(tokenizeJapaneseName: NameSplitTokenizer): Promise<{ + characterPageRequests: number; + firstResultFromCache: boolean; + refreshedNameSplitSource: CharacterDictionarySnapshot['nameSplitSource']; + secondResultFromCache: boolean; +}> { const userDataPath = makeTempDir(); const outputDir = path.join(userDataPath, 'character-dictionaries'); await writeSnapshot(getSnapshotPath(outputDir, 130298), { @@ -172,7 +177,6 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable' }) as typeof globalThis.fetch; try { - let tokenizerCalls = 0; const runtime = createCharacterDictionaryRuntimeService({ userDataPath, getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', @@ -185,29 +189,54 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable' source: 'fallback', }), getNameMatchImagesEnabled: () => false, - tokenizeJapaneseName: async () => { - tokenizerCalls += 1; - return null; - }, + tokenizeJapaneseName, getJapaneseNameTokenizerAvailable: () => true, now: () => 1_700_000_000_500, }); - const result = await runtime.generateForCurrentMedia(); + const firstResult = await runtime.generateForCurrentMedia(); const refreshedSnapshot = JSON.parse( fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'), ) as CharacterDictionarySnapshot; + const secondResult = await runtime.generateForCurrentMedia(); - assert.equal(result.fromCache, false); - assert.equal(refreshedSnapshot.nameSplitSource, 'heuristic'); - - const retriedResult = await runtime.generateForCurrentMedia(); - assert.equal(retriedResult.fromCache, false); - assert.equal(characterPageRequests, 2); - assert.equal(tokenizerCalls, 2); + return { + characterPageRequests, + firstResultFromCache: firstResult.fromCache, + refreshedNameSplitSource: refreshedSnapshot.nameSplitSource, + secondResultFromCache: secondResult.fromCache, + }; } finally { globalThis.fetch = originalFetch; } +} + +test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => { + let tokenizerCalls = 0; + const result = await runNameSplitRefreshScenario(async () => { + tokenizerCalls += 1; + return null; + }); + + assert.equal(result.firstResultFromCache, false); + assert.equal(result.refreshedNameSplitSource, 'heuristic'); + assert.equal(result.secondResultFromCache, false); + assert.equal(result.characterPageRequests, 2); + assert.equal(tokenizerCalls, 2); +}); + +test('generateForCurrentMedia caches completed MeCab refreshes with no resolved splits', async () => { + let tokenizerCalls = 0; + const result = await runNameSplitRefreshScenario(async () => { + tokenizerCalls += 1; + return []; + }); + + assert.equal(result.firstResultFromCache, false); + assert.equal(result.refreshedNameSplitSource, 'mecab'); + assert.equal(result.secondResultFromCache, true); + assert.equal(result.characterPageRequests, 1); + assert.equal(tokenizerCalls, 1); }); test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => { diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index cbbe4beb..252a7f8e 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -132,6 +132,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams { getYoutubeMediaSourceUrl?: AnkiJimakuIpcRuntimeOptions['getYoutubeMediaSourceUrl']; showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification']; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback']; broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged']; getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver']; @@ -334,6 +335,7 @@ export function createAnkiJimakuIpcRuntimeServiceDeps( : {}), showDesktopNotification: params.showDesktopNotification, showOverlayNotification: params.showOverlayNotification, + dismissOverlayNotification: params.dismissOverlayNotification, createFieldGroupingCallback: params.createFieldGroupingCallback, broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged, getFieldGroupingResolver: params.getFieldGroupingResolver, diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 73c61ba0..d8b853d7 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -183,7 +183,10 @@ test('remote media keeps parsed cues when the active subtitle source cannot be r )?.groups?.body; assert.ok(actionBlock); - assert.match(actionBlock, /isYoutubeMediaPath\(videoPath\) \|\| isRemoteMediaPath\(videoPath\)/); + assert.match( + actionBlock, + /isYoutubeMediaPath\(videoPath\) \|\| \(await detectRemoteMediaPath\(videoPath\)\)/, + ); }); test('jellyfin subtitle preload seeds the tokenization prefetch directly', () => { @@ -482,10 +485,10 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/); }); -test('known-word updates invalidate prefetched tokenizations before refreshing current subtitle', () => { +test('subtitle annotation updates invalidate prefetched tokenizations before refreshing current subtitle', () => { const source = readMainSource(); const actionBlock = source.match( - /const refreshCurrentSubtitleAfterKnownWordUpdate = \(\): void => \{(?[\s\S]*?)\n\};/, + /function refreshCurrentSubtitleAnnotations\(\): void \{(?[\s\S]*?)\n\}/, )?.groups?.body; assert.ok(actionBlock); @@ -503,6 +506,20 @@ test('known-word updates invalidate prefetched tokenizations before refreshing c ); }); +test('character portrait index readiness refreshes cached subtitle annotations', () => { + const source = readMainSource(); + const lookupDeps = source.match( + /const characterDictionaryImageLookup = createCharacterDictionaryImageLookup\(\{(?[\s\S]*?)\n\}\);/, + )?.groups?.body; + + assert.ok(lookupDeps); + assert.match(lookupDeps, /onIndexReady: \(\) => refreshCurrentSubtitleAnnotations\(\),/); + assert.match( + lookupDeps, + /onIndexReadyError: \(error\) =>[\s\S]*?logger\.warn\([\s\S]*?character portrait index became ready\.[\s\S]*?error,/, + ); +}); + test('subtitle processing controller resumes prefetch on settle, not on its emits', () => { const source = readMainSource(); const depsBlock = source.match( @@ -846,3 +863,19 @@ test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'), ); }); + +test('main process extracts internal subtitle tracks without a network-mount guard', () => { + const source = readMainSource(); + const resolverWiring = source.match( + /const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler\(\{(?[\s\S]*?)\n\}\);/, + )?.groups?.body; + + assert.ok(resolverWiring); + // Network-mounted files are extracted like local ones; only remote URLs skip + // extraction, handled inside the resolver itself. + assert.doesNotMatch(resolverWiring, /isRemoteMediaPath/); + assert.match( + resolverWiring, + /extractInternalSubtitleTrack:[\s\S]*cachedInternalSubtitleTrackExtractor\.extract/, + ); +}); diff --git a/src/main/runtime/anki-actions-main-deps.test.ts b/src/main/runtime/anki-actions-main-deps.test.ts index 805508f0..d436f022 100644 --- a/src/main/runtime/anki-actions-main-deps.test.ts +++ b/src/main/runtime/anki-actions-main-deps.test.ts @@ -64,11 +64,17 @@ test('anki action main deps builders map callbacks', async () => { const mine = createBuildMineSentenceCardMainDepsHandler({ getAnkiIntegration: () => ({ enabled: true }), getMpvClient: () => ({ connected: true }), + getPrimarySubtitle: () => ({ text: '正式な字幕', startTime: 1, endTime: 3 }), showMpvOsd: (text) => calls.push(`mine:${text}`), mineSentenceCardCore: async () => true, recordCardsMined: (count) => calls.push(`cards:${count}`), })(); assert.deepEqual(mine.getMpvClient(), { connected: true }); + assert.deepEqual(mine.getPrimarySubtitle?.(), { + text: '正式な字幕', + startTime: 1, + endTime: 3, + }); mine.showMpvOsd('m'); await mine.mineSentenceCardCore({ ankiIntegration: { enabled: true }, diff --git a/src/main/runtime/anki-actions-main-deps.ts b/src/main/runtime/anki-actions-main-deps.ts index 76df21d7..5bc8c35b 100644 --- a/src/main/runtime/anki-actions-main-deps.ts +++ b/src/main/runtime/anki-actions-main-deps.ts @@ -1,4 +1,4 @@ -import type { createRefreshKnownWordCacheHandler } from './anki-actions'; +import type { createRefreshKnownWordCacheHandler, PrimarySubtitle } from './anki-actions'; type RefreshKnownWordCacheMainDeps = Parameters[0]; @@ -72,10 +72,12 @@ export function createBuildMarkLastCardAsAudioCardMainDepsHandler(deps: { export function createBuildMineSentenceCardMainDepsHandler(deps: { getAnkiIntegration: () => TAnki; getMpvClient: () => TMpv; + getPrimarySubtitle?: () => PrimarySubtitle | null; showMpvOsd: (text: string) => void; mineSentenceCardCore: (options: { ankiIntegration: TAnki; mpvClient: TMpv; + primarySubtitle?: PrimarySubtitle; showMpvOsd: (text: string) => void; }) => Promise; recordCardsMined: (count: number, noteIds?: number[]) => void; @@ -83,10 +85,14 @@ export function createBuildMineSentenceCardMainDepsHandler(deps: { return () => ({ getAnkiIntegration: () => deps.getAnkiIntegration(), getMpvClient: () => deps.getMpvClient(), + ...(deps.getPrimarySubtitle + ? { getPrimarySubtitle: () => deps.getPrimarySubtitle?.() ?? null } + : {}), showMpvOsd: (text: string) => deps.showMpvOsd(text), mineSentenceCardCore: (options: { ankiIntegration: TAnki; mpvClient: TMpv; + primarySubtitle?: PrimarySubtitle; showMpvOsd: (text: string) => void; }) => deps.mineSentenceCardCore(options), recordCardsMined: (count: number, noteIds?: number[]) => deps.recordCardsMined(count, noteIds), diff --git a/src/main/runtime/anki-actions.test.ts b/src/main/runtime/anki-actions.test.ts index 1c32ab04..ff40e22d 100644 --- a/src/main/runtime/anki-actions.test.ts +++ b/src/main/runtime/anki-actions.test.ts @@ -87,3 +87,20 @@ test('mine sentence handler records mined cards only when core returns true', as await mineSentenceCard(); assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']); }); + +test('mine sentence handler forwards the canonical primary subtitle snapshot', async () => { + const primarySubtitle = { text: '正式な字幕', startTime: 1, endTime: 3 }; + const mineSentenceCard = createMineSentenceCardHandler({ + getAnkiIntegration: () => ({}), + getMpvClient: () => ({}), + getPrimarySubtitle: () => primarySubtitle, + showMpvOsd: () => {}, + mineSentenceCardCore: async (options) => { + assert.equal(options.primarySubtitle, primarySubtitle); + return true; + }, + recordCardsMined: () => {}, + }); + + await mineSentenceCard(); +}); diff --git a/src/main/runtime/anki-actions.ts b/src/main/runtime/anki-actions.ts index f865cc84..2c5c18c2 100644 --- a/src/main/runtime/anki-actions.ts +++ b/src/main/runtime/anki-actions.ts @@ -2,6 +2,12 @@ type AnkiIntegrationLike = { refreshKnownWordCache: () => Promise; }; +export type PrimarySubtitle = { + text: string; + startTime: number; + endTime: number; +}; + export function createUpdateLastCardFromClipboardHandler(deps: { getAnkiIntegration: () => TAnki; readClipboardText: () => string; @@ -69,18 +75,22 @@ export function createMarkLastCardAsAudioCardHandler(deps: { export function createMineSentenceCardHandler(deps: { getAnkiIntegration: () => TAnki; getMpvClient: () => TMpv; + getPrimarySubtitle?: () => PrimarySubtitle | null; showMpvOsd: (text: string) => void; mineSentenceCardCore: (options: { ankiIntegration: TAnki; mpvClient: TMpv; + primarySubtitle?: PrimarySubtitle; showMpvOsd: (text: string) => void; }) => Promise; recordCardsMined: (count: number, noteIds?: number[]) => void; }) { return async (): Promise => { + const primarySubtitle = deps.getPrimarySubtitle?.(); const created = await deps.mineSentenceCardCore({ ankiIntegration: deps.getAnkiIntegration(), mpvClient: deps.getMpvClient(), + ...(primarySubtitle ? { primarySubtitle } : {}), showMpvOsd: deps.showMpvOsd, }); if (created) { diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts index 11e75df0..5ab6227c 100644 --- a/src/main/runtime/app-lifecycle-actions.test.ts +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -43,6 +43,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => { destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'), clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), + cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'), cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), @@ -50,10 +51,11 @@ test('on will quit cleanup handler runs all cleanup steps', () => { }); cleanup(); - assert.equal(calls.length, 34); + assert.equal(calls.length, 35); assert.equal(calls[0], 'destroy-tray'); assert.equal(calls[calls.length - 1], 'stop-discord-presence'); assert.ok(calls.includes('cleanup-jellyfin-subtitles')); + assert.ok(calls.includes('cleanup-internal-subtitles')); assert.ok(calls.includes('clear-windows-visible-overlay-poll')); assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts')); assert.ok(calls.includes('cleanup-youtube-subtitles')); @@ -97,6 +99,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping calls.push('stop-jellyfin-remote'); throw new Error('stop failed'); }, + cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'), cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), @@ -104,7 +107,11 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping }); assert.throws(() => cleanup(), /stop failed/); - assert.deepEqual(calls, ['stop-jellyfin-remote', 'cleanup-jellyfin-subtitles']); + assert.deepEqual(calls, [ + 'stop-jellyfin-remote', + 'cleanup-jellyfin-subtitles', + 'cleanup-internal-subtitles', + ]); }); test('should restore windows on activate requires initialized runtime and no windows', () => { diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts index 16f4130a..c0b77a0a 100644 --- a/src/main/runtime/app-lifecycle-actions.ts +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -29,6 +29,7 @@ export function createOnWillQuitCleanupHandler(deps: { destroyYomitanSettingsWindow: () => void; clearYomitanSettingsWindow: () => void; stopJellyfinRemoteSession: () => void; + cleanupInternalSubtitleTrackCache: () => void; cleanupYoutubeSubtitleTempDirs: () => void; cleanupYoutubeMediaCache: () => void; cleanupJellyfinSubtitleCache: () => void; @@ -67,7 +68,11 @@ export function createOnWillQuitCleanupHandler(deps: { try { deps.stopJellyfinRemoteSession(); } finally { - deps.cleanupJellyfinSubtitleCache(); + try { + deps.cleanupJellyfinSubtitleCache(); + } finally { + deps.cleanupInternalSubtitleTrackCache(); + } } deps.cleanupYoutubeSubtitleTempDirs(); deps.cleanupYoutubeMediaCache(); diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts index 373df2f9..f12df1e7 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.test.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -72,6 +72,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), + cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'), cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), @@ -95,6 +96,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' assert.ok(calls.includes('destroy-first-run-window')); assert.ok(calls.includes('destroy-yomitan-settings-window')); assert.ok(calls.includes('stop-jellyfin-remote')); + assert.ok(calls.includes('cleanup-internal-subtitles')); assert.ok(calls.includes('cleanup-youtube-subtitles')); assert.ok(calls.includes('cleanup-youtube-media')); assert.ok(calls.includes('cleanup-jellyfin-subtitles')); @@ -152,6 +154,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => { getYomitanSettingsWindow: () => null, clearYomitanSettingsWindow: () => {}, stopJellyfinRemoteSession: () => {}, + cleanupInternalSubtitleTrackCache: () => {}, cleanupYoutubeSubtitleTempDirs: () => {}, cleanupYoutubeMediaCache: () => {}, cleanupJellyfinSubtitleCache: () => {}, @@ -204,6 +207,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () = getYomitanSettingsWindow: () => null, clearYomitanSettingsWindow: () => {}, stopJellyfinRemoteSession: () => {}, + cleanupInternalSubtitleTrackCache: () => {}, cleanupYoutubeSubtitleTempDirs: () => {}, cleanupYoutubeMediaCache: () => {}, cleanupJellyfinSubtitleCache: () => {}, diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts index 54b751f7..249d1618 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -58,6 +58,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { clearYomitanSettingsWindow: () => void; stopJellyfinRemoteSession: () => void; + cleanupInternalSubtitleTrackCache: () => void; cleanupYoutubeSubtitleTempDirs: () => void; cleanupYoutubeMediaCache: () => void; cleanupJellyfinSubtitleCache: () => void; @@ -144,6 +145,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { }, clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(), stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(), + cleanupInternalSubtitleTrackCache: () => deps.cleanupInternalSubtitleTrackCache(), cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(), cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(), cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(), diff --git a/src/main/runtime/autoplay-subtitle-priming-runtime.test.ts b/src/main/runtime/autoplay-subtitle-priming-runtime.test.ts index 25f0ef52..0a2c1f4d 100644 --- a/src/main/runtime/autoplay-subtitle-priming-runtime.test.ts +++ b/src/main/runtime/autoplay-subtitle-priming-runtime.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser'; import { createSubtitleProcessingController } from '../../core/services/subtitle-processing-controller'; import type { SubtitleData } from '../../types'; import { @@ -211,6 +212,69 @@ test('primeCurrentSubtitleForAutoplay emits raw first paint on cache miss before ]); }); +test('parsed cues replace a duplicate raw autoplay subtitle that was already primed', async () => { + const rawText = 'ジグザグな道を抜け\nジグザグな道を抜け'; + const correctedText = 'ジグザグな道を抜け'; + const mediaPath = '/media/video.mkv'; + let currentSubText = ''; + const emitted: string[] = []; + const client = { + connected: true, + currentVideoPath: mediaPath, + currentTimePos: 90, + currentSubText: rawText, + requestProperty: async (name: string) => { + if (name === 'sub-text') return rawText; + if (name === 'time-pos') return 90; + return null; + }, + }; + const cues = parseSubtitleCues( + [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + `Dialogue: 1,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,${correctedText}`, + `Dialogue: 0,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,${correctedText}`, + ].join('\n'), + 'startup-ending.ass', + ); + let activeCues = cues.slice(0, 0); + const runtime = createAutoplaySubtitlePrimingRuntime({ + getCurrentMediaPath: () => mediaPath, + getMpvClient: () => client, + setCurrentSubText: (text) => { + currentSubText = text; + }, + getCurrentSubText: () => currentSubText, + getCurrentSubtitleData: () => null, + getActiveParsedSubtitleCues: () => activeCues, + setActiveParsedSubtitleMediaPath: () => {}, + subtitleProcessingController: { + consumeCachedSubtitle: () => null, + onSubtitleChange: () => true, + refreshCurrentSubtitle: () => true, + notePlainSubtitleEmitted: () => {}, + }, + emitSubtitlePayload: (payload) => emitted.push(payload.text), + getSubtitlePrefetchService: () => null, + getLastObservedTimePos: () => 90, + getVisibleOverlayVisible: () => true, + emitSecondarySubtitle: () => {}, + initSubtitlePrefetch: async () => {}, + refreshSubtitlePrefetchFromActiveTrack: async () => {}, + logDebug: () => {}, + }); + + await runtime.primeCurrentSubtitleForAutoplay(mediaPath); + assert.equal(currentSubText, rawText); + + activeCues = cues; + await runtime.primeAutoplaySubtitleFromParsedCues(mediaPath, cues); + + assert.equal(currentSubText, correctedText); + assert.deepEqual(emitted, [rawText, correctedText]); +}); + // Driven by the real processing controller rather than a stub: the failure this // covers is a disagreement between the priming path and the controller's own // staleness rules, which a hand-written stub cannot reproduce. diff --git a/src/main/runtime/autoplay-subtitle-priming-runtime.ts b/src/main/runtime/autoplay-subtitle-priming-runtime.ts index 708fd259..3ce188a2 100644 --- a/src/main/runtime/autoplay-subtitle-priming-runtime.ts +++ b/src/main/runtime/autoplay-subtitle-priming-runtime.ts @@ -1,6 +1,7 @@ import type { SubtitleCue, SubtitleData } from '../../types'; import { selectAutoplayStartupCue } from './autoplay-subtitle-primer'; import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot'; +import { resolvePrimarySubtitleText } from './primary-subtitle-text'; import { resolveSubtitleSourcePath } from './subtitle-prefetch-source'; const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2; @@ -11,6 +12,7 @@ type AutoplaySubtitlePrimingMpvClient = { requestProperty: (name: string) => Promise; currentVideoPath?: string; currentTimePos?: number; + currentSubText?: string; currentSecondarySubText?: string; setCurrentSecondarySubText?: (text: string) => void; }; @@ -106,11 +108,19 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi autoplaySubtitlePrimedMediaPath = null; } - function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean { + function emitAutoplayPrimedSubtitle( + mediaPath: string, + text: string, + options: { replaceExisting?: boolean } = {}, + ): boolean { if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) { return false; } - if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) { + if (autoplaySubtitlePrimedMediaPath === mediaPath) { + if (!options.replaceExisting || deps.getCurrentSubText() === text) { + return false; + } + } else if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) { return false; } @@ -141,6 +151,16 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi return true; } + function resolveLivePrimarySubtitleText(text: string): string { + const client = deps.getMpvClient(); + const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos()); + return resolvePrimarySubtitleText({ + liveText: text, + currentTimeSec, + cues: deps.getActiveParsedSubtitleCues(), + }); + } + async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise { const client = deps.getMpvClient(); if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) { @@ -155,7 +175,8 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi ); return null; }); - const text = typeof subTextRaw === 'string' ? subTextRaw : ''; + const liveText = typeof subTextRaw === 'string' ? subTextRaw : ''; + const text = resolveLivePrimarySubtitleText(liveText); if (emitAutoplayPrimedSubtitle(mediaPath, text)) { return; } @@ -175,6 +196,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi async function primeCurrentSubtitleForVisibleOverlay(): Promise { await primeVisibleOverlaySubtitleFromMpv({ getMpvClient: () => deps.getMpvClient(), + resolvePrimarySubtitleText: (text) => resolveLivePrimarySubtitleText(text), setCurrentSubText: (text) => { deps.setCurrentSubText(text); }, @@ -239,11 +261,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi mediaPath: string, cues: SubtitleCue[], ): Promise { - if ( - cues.length === 0 || - autoplaySubtitlePrimedMediaPath === mediaPath || - !isCurrentAutoplayMediaPath(mediaPath) - ) { + if (cues.length === 0 || !isCurrentAutoplayMediaPath(mediaPath)) { return; } @@ -252,16 +270,21 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi const currentTimeSeconds = Number( timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0, ); + const resolvedTimeSeconds = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0; const cue = selectAutoplayStartupCue( cues, - Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0, + resolvedTimeSeconds, AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS, ); - if (!cue) { + const liveText = client?.currentSubText ?? ''; + const text = liveText.trim() + ? resolvePrimarySubtitleText({ liveText, currentTimeSec: resolvedTimeSeconds, cues }) + : (cue?.text ?? ''); + if (!text) { return; } - emitAutoplayPrimedSubtitle(mediaPath, cue.text); + emitAutoplayPrimedSubtitle(mediaPath, text, { replaceExisting: true }); } function clearScheduledSubtitlePrefetchRefresh(): void { diff --git a/src/main/runtime/composers/startup-lifecycle-composer.test.ts b/src/main/runtime/composers/startup-lifecycle-composer.test.ts index b4db3e1c..fbe18443 100644 --- a/src/main/runtime/composers/startup-lifecycle-composer.test.ts +++ b/src/main/runtime/composers/startup-lifecycle-composer.test.ts @@ -49,6 +49,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler getYomitanSettingsWindow: () => null, clearYomitanSettingsWindow: () => {}, stopJellyfinRemoteSession: async () => {}, + cleanupInternalSubtitleTrackCache: () => {}, cleanupYoutubeSubtitleTempDirs: () => {}, cleanupYoutubeMediaCache: () => {}, cleanupJellyfinSubtitleCache: () => {}, diff --git a/src/main/runtime/current-subtitle-snapshot.ts b/src/main/runtime/current-subtitle-snapshot.ts index 09c3beb3..5317a0da 100644 --- a/src/main/runtime/current-subtitle-snapshot.ts +++ b/src/main/runtime/current-subtitle-snapshot.ts @@ -46,6 +46,7 @@ export async function resolveCurrentSubtitleForRenderer(deps: { export async function primeVisibleOverlaySubtitleFromMpv(deps: { getMpvClient: () => CurrentSubtitleMpvClient | null; setCurrentSubText: (text: string) => void; + resolvePrimarySubtitleText?: (text: string) => string; getCurrentSubtitleData: () => SubtitleData | null; consumeCachedSubtitle: (text: string) => SubtitleData | null; onSubtitleChange: (text: string) => void; @@ -73,7 +74,8 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: { return; } - const text = typeof subTextRaw === 'string' ? subTextRaw : ''; + const liveText = typeof subTextRaw === 'string' ? subTextRaw : ''; + const text = deps.resolvePrimarySubtitleText?.(liveText) ?? liveText; deps.setCurrentSubText(text); const primeSecondarySubtitle = async (): Promise => { diff --git a/src/main/runtime/internal-subtitle-extraction.test.ts b/src/main/runtime/internal-subtitle-extraction.test.ts index 658d4ea8..1aee8b87 100644 --- a/src/main/runtime/internal-subtitle-extraction.test.ts +++ b/src/main/runtime/internal-subtitle-extraction.test.ts @@ -6,6 +6,7 @@ import process from 'node:process'; import test from 'node:test'; import { buildFfmpegSubtitleExtractionArgs, + createCachedInternalSubtitleTrackExtractor, extractInternalSubtitleTrackToTempFile, parseTrackId, } from './internal-subtitle-extraction'; @@ -22,6 +23,65 @@ test('parseTrackId rejects negative track ids', () => { assert.equal(parseTrackId(' -2 '), null); }); +test('cached internal subtitle extraction shares concurrent and repeated track requests', async () => { + let extractionCalls = 0; + let cleanupCalls = 0; + let resolveExtraction: + | ((result: { path: string; cleanup: () => Promise }) => void) + | undefined; + const firstExtraction = new Promise<{ path: string; cleanup: () => Promise }>((resolve) => { + resolveExtraction = resolve; + }); + const extractor = createCachedInternalSubtitleTrackExtractor({ + extract: async () => { + extractionCalls += 1; + if (extractionCalls === 1) { + return firstExtraction; + } + return { + path: `/tmp/subtitle-${extractionCalls}.ass`, + cleanup: async () => { + cleanupCalls += 1; + }, + }; + }, + }); + const request = () => + extractor.extract('ffmpeg', '/Volumes/media/episode.mkv', { + 'ff-index': 3, + codec: 'ass', + }); + + const concurrent = Array.from({ length: 6 }, request); + assert.equal(extractionCalls, 1); + if (!resolveExtraction) { + throw new Error('extraction did not start'); + } + resolveExtraction({ + path: '/tmp/subtitle-1.ass', + cleanup: async () => { + cleanupCalls += 1; + }, + }); + + const results = await Promise.all(concurrent); + assert.deepEqual( + results.map((result) => result?.path), + Array.from({ length: 6 }, () => '/tmp/subtitle-1.ass'), + ); + await Promise.all(results.map((result) => result?.cleanup())); + assert.equal(cleanupCalls, 0); + + assert.equal((await request())?.path, '/tmp/subtitle-1.ass'); + assert.equal(extractionCalls, 1); + + extractor.clear(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(cleanupCalls, 1); + assert.equal((await request())?.path, '/tmp/subtitle-2.ass'); + assert.equal(extractionCalls, 2); +}); + test('extractInternalSubtitleTrackToTempFile times out stalled ffmpeg process', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-ffmpeg-timeout-')); const videoPath = path.join(root, 'video.mkv'); diff --git a/src/main/runtime/internal-subtitle-extraction.ts b/src/main/runtime/internal-subtitle-extraction.ts index d303466d..d5beae55 100644 --- a/src/main/runtime/internal-subtitle-extraction.ts +++ b/src/main/runtime/internal-subtitle-extraction.ts @@ -35,7 +35,21 @@ export type MpvSubtitleTrackLike = { 'external-filename'?: unknown; }; -const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000; +export type ExtractedInternalSubtitleTrack = { + path: string; + cleanup: () => Promise; +}; + +export type InternalSubtitleTrackExtractor = ( + ffmpegPath: string, + videoPath: string, + track: MpvSubtitleTrackLike, +) => Promise; + +// Subtitle packets are interleaved through the container, so extraction reads the +// entire file. Network mounts move ~100 MB/s on gigabit, so large Bluray remuxes +// need well over 30 seconds. +const DEFAULT_EXTRACTION_TIMEOUT_MS = 120_000; export function parseTrackId(value: unknown): number | null { if (typeof value === 'number' && Number.isInteger(value) && value >= 0) { @@ -80,7 +94,7 @@ export async function extractInternalSubtitleTrackToTempFile( videoPath: string, track: MpvSubtitleTrackLike, options: { extractionTimeoutMs?: number; spawnArgsOverride?: string[] } = {}, -): Promise<{ path: string; cleanup: () => Promise } | null> { +): Promise { const ffIndex = parseTrackId(track['ff-index']); const codec = typeof track.codec === 'string' ? track.codec : null; const extension = codecToExtension(codec ?? undefined); @@ -145,3 +159,69 @@ export async function extractInternalSubtitleTrackToTempFile( }, }; } + +type CachedExtraction = { + promise: Promise; +}; + +function buildCachedExtractionKey( + ffmpegPath: string, + videoPath: string, + track: MpvSubtitleTrackLike, +): string { + const codec = typeof track.codec === 'string' ? track.codec : null; + return JSON.stringify([ffmpegPath, videoPath, parseTrackId(track['ff-index']), codec]); +} + +const releaseCachedExtraction = async (): Promise => {}; + +/** + * Owns extracted subtitle files for the active media and shares one extraction between callers. + * Caller cleanup releases only its view; clear removes the owned files on media changes or quit. + */ +export function createCachedInternalSubtitleTrackExtractor( + deps: { extract?: InternalSubtitleTrackExtractor } = {}, +): { + extract: InternalSubtitleTrackExtractor; + clear: () => void; +} { + const extractTrack = deps.extract ?? extractInternalSubtitleTrackToTempFile; + const extractions = new Map(); + + const extract: InternalSubtitleTrackExtractor = async (ffmpegPath, videoPath, track) => { + const key = buildCachedExtractionKey(ffmpegPath, videoPath, track); + let cached = extractions.get(key); + if (!cached) { + const next: CachedExtraction = { + promise: extractTrack(ffmpegPath, videoPath, track), + }; + cached = next; + extractions.set(key, next); + void next.promise.catch(() => { + if (extractions.get(key) === next) { + extractions.delete(key); + } + }); + } + + const result = await cached.promise; + if (extractions.get(key) !== cached || !result) { + return null; + } + + return { + path: result.path, + cleanup: releaseCachedExtraction, + }; + }; + + const clear = (): void => { + const staleExtractions = [...extractions.values()]; + extractions.clear(); + for (const extraction of staleExtractions) { + void extraction.promise.then((result) => result?.cleanup()).catch(() => undefined); + } + }; + + return { extract, clear }; +} diff --git a/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts b/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts index bb79a4de..f6f4d1b5 100644 --- a/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts +++ b/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts @@ -19,19 +19,6 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy return { path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' }; }, cleanupCachedSubtitles: () => calls.push('cleanup'), - getSavedSubtitleDelay: (_itemId, streamIndex) => { - calls.push(`load-delay:${streamIndex}`); - return 1.25; - }, - setActiveSubtitleDelayKey: (key) => calls.push(`active-delay:${key?.streamIndex ?? 'none'}`), - loadSubtitleSourceText: async (source) => { - calls.push(`load-source:${source}`); - return 'subtitle'; - }, - saveSubtitleDelay: (_itemId, streamIndex, delaySeconds) => { - calls.push(`save-delay:${streamIndex}:${delaySeconds}`); - return true; - }, logDebug: (message) => calls.push(`debug:${message}`), })(); @@ -41,21 +28,6 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy await deps.wait(1); await deps.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' }); deps.cleanupCachedSubtitles(['/tmp/subs']); - assert.equal(deps.getSavedSubtitleDelay?.('item', 3), 1.25); - deps.setActiveSubtitleDelayKey?.({ itemId: 'item', streamIndex: 3 }); - assert.equal(await deps.loadSubtitleSourceText?.('/tmp/sub.srt'), 'subtitle'); - assert.equal(deps.saveSubtitleDelay?.('item', 3, -31.5), true); deps.logDebug('oops', null); - assert.deepEqual(calls, [ - 'list', - 'send', - 'wait', - 'cache', - 'cleanup', - 'load-delay:3', - 'active-delay:3', - 'load-source:/tmp/sub.srt', - 'save-delay:3:-31.5', - 'debug:oops', - ]); + assert.deepEqual(calls, ['list', 'send', 'wait', 'cache', 'cleanup', 'debug:oops']); }); diff --git a/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts b/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts index f5ca73a7..b00f08ff 100644 --- a/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts +++ b/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts @@ -15,19 +15,6 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler( wait: (ms: number) => deps.wait(ms), cacheSubtitleTrack: (track) => deps.cacheSubtitleTrack(track), cleanupCachedSubtitles: (dirs) => deps.cleanupCachedSubtitles(dirs), - getSavedSubtitleDelay: deps.getSavedSubtitleDelay - ? (itemId, streamIndex) => deps.getSavedSubtitleDelay!(itemId, streamIndex) - : undefined, - setActiveSubtitleDelayKey: deps.setActiveSubtitleDelayKey - ? (key) => deps.setActiveSubtitleDelayKey!(key) - : undefined, - loadSubtitleSourceText: deps.loadSubtitleSourceText - ? (source) => deps.loadSubtitleSourceText!(source) - : undefined, - saveSubtitleDelay: deps.saveSubtitleDelay - ? (itemId, streamIndex, delaySeconds) => - deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds) - : undefined, initSubtitlePrefetch: deps.initSubtitlePrefetch ? (sourcePath) => deps.initSubtitlePrefetch!(sourcePath) : undefined, diff --git a/src/main/runtime/jellyfin-subtitle-preload.test.ts b/src/main/runtime/jellyfin-subtitle-preload.test.ts index 8477f174..86a405fb 100644 --- a/src/main/runtime/jellyfin-subtitle-preload.test.ts +++ b/src/main/runtime/jellyfin-subtitle-preload.test.ts @@ -32,14 +32,6 @@ function makeDeps(overrides: { cleanupCachedSubtitles?: Parameters< typeof createPreloadJellyfinExternalSubtitlesHandler >[0]['cleanupCachedSubtitles']; - getSavedSubtitleDelay?: Parameters< - typeof createPreloadJellyfinExternalSubtitlesHandler - >[0]['getSavedSubtitleDelay']; - setActiveSubtitleDelayKey?: Parameters< - typeof createPreloadJellyfinExternalSubtitlesHandler - >[0]['setActiveSubtitleDelayKey']; - loadSubtitleSourceText?: (source: string) => Promise; - saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void; initSubtitlePrefetch?: Parameters< typeof createPreloadJellyfinExternalSubtitlesHandler >[0]['initSubtitlePrefetch']; @@ -57,10 +49,6 @@ function makeDeps(overrides: { cleanupDir: '/tmp/subminer-jellyfin-subtitles', })), cleanupCachedSubtitles: overrides.cleanupCachedSubtitles ?? (() => {}), - getSavedSubtitleDelay: overrides.getSavedSubtitleDelay, - setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey, - loadSubtitleSourceText: overrides.loadSubtitleSourceText, - saveSubtitleDelay: overrides.saveSubtitleDelay, initSubtitlePrefetch: overrides.initSubtitlePrefetch, logDebug: overrides.logDebug ?? (() => {}), }; @@ -377,20 +365,17 @@ test('preload jellyfin subtitles waits for delayed external japanese track inste test('preload jellyfin subtitles clears managed delay when no external tracks are available', async () => { const commands: Array> = []; - const activeDelayKeys: Array = []; const preload = createPreloadJellyfinExternalSubtitlesHandler( makeDeps({ listJellyfinSubtitleTracks: async () => [ { index: 0, language: 'jpn', title: 'Embedded Japanese' }, ], sendMpvCommand: (command) => commands.push(command), - setActiveSubtitleDelayKey: (key) => activeDelayKeys.push(key), }), ); await preload({ session, clientInfo, itemId: 'item-1' }); - assert.deepEqual(activeDelayKeys, [null]); assert.deepEqual(commands, [['set_property', 'sub-delay', 0]]); }); @@ -461,42 +446,7 @@ test('preload jellyfin subtitles prefers Jellyfin default and embedded japanese ]); }); -test('preload jellyfin subtitles applies saved delay for selected japanese stream', async () => { - const commands: Array> = []; - const activeKeys: Array<{ itemId: string; streamIndex: number } | null> = []; - const preload = createPreloadJellyfinExternalSubtitlesHandler( - makeDeps({ - listJellyfinSubtitleTracks: async () => [ - { index: 3, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' }, - ], - getMpvClient: () => ({ - requestProperty: async () => [ - { - type: 'sub', - id: 11, - lang: 'jpn', - title: 'Japanese', - external: true, - 'external-filename': '/tmp/subminer-jellyfin-subtitles/3.srt', - }, - ], - }), - sendMpvCommand: (command) => commands.push(command), - getSavedSubtitleDelay: (_itemId, streamIndex) => (streamIndex === 3 ? 1.25 : null), - setActiveSubtitleDelayKey: (key) => activeKeys.push(key), - }), - ); - - await preload({ session, clientInfo, itemId: 'item-9' }); - - assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [ - ['set_property', 'sub-delay', 1.25], - ['set_property', 'sid', 11], - ]); - assert.deepEqual(activeKeys, [{ itemId: 'item-9', streamIndex: 3 }]); -}); - -test('preload jellyfin subtitles applies saved delay before selecting japanese stream', async () => { +test('preload jellyfin subtitles resets delay before selecting japanese stream', async () => { const commands: Array> = []; const preload = createPreloadJellyfinExternalSubtitlesHandler( makeDeps({ @@ -516,14 +466,13 @@ test('preload jellyfin subtitles applies saved delay before selecting japanese s ], }), sendMpvCommand: (command) => commands.push(command), - getSavedSubtitleDelay: () => 1.25, }), ); await preload({ session, clientInfo, itemId: 'item-9' }); const delayIndex = commands.findIndex( - (command) => command[0] === 'set_property' && command[1] === 'sub-delay' && command[2] === 1.25, + (command) => command[0] === 'set_property' && command[1] === 'sub-delay' && command[2] === 0, ); const selectedSidIndex = commands.findIndex( (command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 11, @@ -533,143 +482,6 @@ test('preload jellyfin subtitles applies saved delay before selecting japanese s assert.ok(delayIndex < selectedSidIndex); }); -test('preload jellyfin subtitles auto-aligns late japanese track from english reference', async () => { - const commands: Array> = []; - const savedDelays: Array<{ itemId: string; streamIndex: number; delaySeconds: number }> = []; - const primarySrt = `1 -00:00:34,935 --> 00:00:36,937 -Japanese 1 - -2 -00:00:36,937 --> 00:00:41,441 -Japanese 2 - -3 -00:00:41,441 --> 00:00:45,279 -Japanese 3 - -4 -00:00:45,279 --> 00:00:48,115 -Japanese 4 - -5 -00:00:48,115 --> 00:00:52,286 -Japanese 5 - -6 -00:00:52,286 --> 00:00:54,955 -Japanese 6 - -7 -00:00:54,955 --> 00:00:59,793 -Japanese 7 - -8 -00:00:59,793 --> 00:01:03,630 -Japanese 8 - -9 -00:01:03,630 --> 00:01:07,634 -Japanese 9 - -10 -00:01:07,634 --> 00:01:13,040 -Japanese 10 - -11 -00:01:16,643 --> 00:01:20,814 -Japanese 11 - -12 -00:01:20,814 --> 00:01:23,116 -Japanese 12 - -13 -00:01:27,988 --> 00:01:30,991 -Japanese 13 - -14 -00:01:30,991 --> 00:01:34,094 -Japanese 14 - -15 -00:01:34,094 --> 00:01:37,097 -Japanese 15 - -16 -00:01:37,097 --> 00:01:39,100 -Japanese 16 -`; - const referenceAss = `[Events] -Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text -Dialogue: 0,0:00:03.46,0:00:08.73,Default,,0,0,0,,English 1 -Dialogue: 0,0:00:09.48,0:00:13.61,Default,,0,0,0,,English 2 -Dialogue: 0,0:00:13.61,0:00:19.64,Default,,0,0,0,,English 3 -Dialogue: 0,0:00:21.40,0:00:27.32,Default,,0,0,0,,English 4 -Dialogue: 0,0:00:28.16,0:00:31.75,Default,,0,0,0,,English 5 -Dialogue: 0,0:00:32.06,0:00:34.52,Default,,0,0,0,,English 6 -Dialogue: 0,0:00:35.93,0:00:40.57,Default,,0,0,0,,English 7 -Dialogue: 0,0:00:45.10,0:00:51.01,Default,,0,0,0,,English 8 -Dialogue: 0,0:00:56.57,0:00:59.12,Default,,0,0,0,,English 9 -Dialogue: 0,0:00:59.68,0:01:02.44,Default,,0,0,0,,English 10 -Dialogue: 0,0:01:02.44,0:01:05.56,Default,,0,0,0,,English 11 -Dialogue: 0,0:01:05.56,0:01:06.87,Default,,0,0,0,,English 12 -`; - const preload = createPreloadJellyfinExternalSubtitlesHandler( - makeDeps({ - listJellyfinSubtitleTracks: async () => [ - { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' }, - { index: 4, language: 'eng', title: 'English', deliveryUrl: 'https://sub/eng.ass' }, - ], - getMpvClient: () => ({ - requestProperty: async () => [ - { - type: 'sub', - id: 10, - lang: 'jpn', - title: 'Japanese', - external: true, - 'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt', - }, - { - type: 'sub', - id: 12, - lang: 'eng', - title: 'English', - external: true, - 'external-filename': '/tmp/subminer-jellyfin-subtitles/4.ass', - }, - ], - }), - sendMpvCommand: (command) => commands.push(command), - cacheSubtitleTrack: async (track) => ({ - path: `/tmp/subminer-jellyfin-subtitles/${track.index}.${track.index === 4 ? 'ass' : 'srt'}`, - cleanupDir: '/tmp/subminer-jellyfin-subtitles', - }), - getSavedSubtitleDelay: () => null, - loadSubtitleSourceText: async (source) => - source.endsWith('.ass') ? referenceAss : primarySrt, - saveSubtitleDelay: (itemId, streamIndex, delaySeconds) => { - savedDelays.push({ itemId, streamIndex, delaySeconds }); - }, - }), - ); - - await preload({ session, clientInfo, itemId: 'item-9' }); - - const delayCommand = commands.find( - (command) => command[0] === 'set_property' && command[1] === 'sub-delay', - ); - assert.ok(delayCommand); - const delaySeconds = delayCommand[2]; - if (typeof delaySeconds !== 'number') { - assert.fail('Expected numeric subtitle delay.'); - } - assert.ok(delaySeconds > -32); - assert.ok(delaySeconds < -31); - assert.deepEqual(savedDelays, [{ itemId: 'item-9', streamIndex: 0, delaySeconds }]); -}); - test('preload jellyfin subtitles accepts numeric string mpv track ids', async () => { const commands: Array> = []; const preload = createPreloadJellyfinExternalSubtitlesHandler( diff --git a/src/main/runtime/jellyfin-subtitle-preload.ts b/src/main/runtime/jellyfin-subtitle-preload.ts index 5843075f..dcd12518 100644 --- a/src/main/runtime/jellyfin-subtitle-preload.ts +++ b/src/main/runtime/jellyfin-subtitle-preload.ts @@ -1,6 +1,3 @@ -import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser'; -import { estimateSubtitleTimingOffset } from '../../core/services/subtitle-timing-offset'; - type JellyfinSession = { serverUrl: string; accessToken: string; @@ -35,11 +32,6 @@ type CachedExternalSubtitleTrack = CachedSubtitleTrack & { source: JellyfinSubtitleTrack; }; -type JellyfinSubtitleDelayKey = { - itemId: string; - streamIndex: number; -}; - type MpvSubtitleTrack = { id: number; lang: string; @@ -257,54 +249,6 @@ async function waitForPreferredSubtitleTracks( return subtitleTracks; } -async function estimateSubtitleDelayFromReference( - deps: { - loadSubtitleSourceText?: (source: string) => Promise; - logDebug: (message: string, error: unknown) => void; - }, - primaryTrack: CachedExternalSubtitleTrack | null, - referenceTrack: CachedExternalSubtitleTrack | null, -): Promise { - if (!deps.loadSubtitleSourceText || !primaryTrack || !referenceTrack) { - return null; - } - - try { - const [primaryContent, referenceContent] = await Promise.all([ - deps.loadSubtitleSourceText(primaryTrack.path), - deps.loadSubtitleSourceText(referenceTrack.path), - ]); - const primaryCues = parseSubtitleCues(primaryContent, primaryTrack.path); - const referenceCues = parseSubtitleCues(referenceContent, referenceTrack.path); - return estimateSubtitleTimingOffset(primaryCues, referenceCues)?.offsetSeconds ?? null; - } catch (error) { - deps.logDebug('Failed to auto-align Jellyfin subtitle timing', error); - return null; - } -} - -function saveEstimatedSubtitleDelay( - deps: { - saveSubtitleDelay?: ( - itemId: string, - streamIndex: number, - delaySeconds: number, - ) => boolean | void; - logDebug: (message: string, error: unknown) => void; - }, - key: JellyfinSubtitleDelayKey, - delaySeconds: number, -): void { - try { - const saved = deps.saveSubtitleDelay?.(key.itemId, key.streamIndex, delaySeconds); - if (saved === false) { - deps.logDebug('Failed to save Jellyfin auto subtitle delay', key); - } - } catch (error) { - deps.logDebug('Failed to save Jellyfin auto subtitle delay', error); - } -} - export function createPreloadJellyfinExternalSubtitlesHandler(deps: { listJellyfinSubtitleTracks: ( session: JellyfinSession, @@ -316,10 +260,6 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { wait: (ms: number) => Promise; cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise; cleanupCachedSubtitles: (dirs: string[]) => void; - getSavedSubtitleDelay?: (itemId: string, streamIndex: number) => number | null; - setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void; - loadSubtitleSourceText?: (source: string) => Promise; - saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void; initSubtitlePrefetch?: (sourcePath: string) => void | Promise; logDebug: (message: string, error: unknown) => void; }): PreloadJellyfinExternalSubtitlesHandler { @@ -357,6 +297,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { itemId: string; }): Promise => { try { + resetManagedSubtitleDelay(); try { cleanupActiveCache(); } catch (error) { @@ -369,8 +310,6 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { ); const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl)); if (externalTracks.length === 0) { - deps.setActiveSubtitleDelayKey?.(null); - resetManagedSubtitleDelay(); return; } @@ -427,40 +366,13 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { japanesePrimaryId, ); if (selectedCachedTrack) { - const delayKey = { itemId: params.itemId, streamIndex: selectedCachedTrack.source.index }; - deps.setActiveSubtitleDelayKey?.(delayKey); - const savedDelay = deps.getSavedSubtitleDelay?.(delayKey.itemId, delayKey.streamIndex); - if (typeof savedDelay === 'number' && Number.isFinite(savedDelay)) { - deps.sendMpvCommand(['set_property', 'sub-delay', savedDelay]); - } else { - const referenceCachedTrack = findCachedTrackForMpvTrackId( - resolvedSubtitleTracks, - cachedTracks, - englishSecondaryId, - ); - const estimatedDelay = await estimateSubtitleDelayFromReference( - deps, - selectedCachedTrack, - referenceCachedTrack, - ); - if (estimatedDelay !== null) { - deps.sendMpvCommand(['set_property', 'sub-delay', estimatedDelay]); - saveEstimatedSubtitleDelay(deps, delayKey, estimatedDelay); - } else { - resetManagedSubtitleDelay(); - } - } deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]); startSubtitlePrefetchForCachedTrack(selectedCachedTrack.path); } else { - deps.setActiveSubtitleDelayKey?.(null); - resetManagedSubtitleDelay(); deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]); } } else { deps.sendMpvCommand(['set_property', 'sid', 'no']); - deps.setActiveSubtitleDelayKey?.(null); - resetManagedSubtitleDelay(); } if (englishSecondaryId !== null) { diff --git a/src/main/runtime/linux-runtime-plugin-assets.test.ts b/src/main/runtime/linux-runtime-plugin-assets.test.ts index 551d896b..9ad93fda 100644 --- a/src/main/runtime/linux-runtime-plugin-assets.test.ts +++ b/src/main/runtime/linux-runtime-plugin-assets.test.ts @@ -8,6 +8,18 @@ import { resolveManagedLinuxRuntimePluginPaths, } from './linux-runtime-plugin-assets'; +const THUMBNAILER_RELATIVE_PATH = path.join( + 'thumbnailers', + 'subminer-ffmpegthumbnailer.thumbnailer', +); + +function writeThumbnailer(rootDir: string, content = '[Thumbnailer Entry]\n'): string { + const thumbnailerPath = path.join(rootDir, THUMBNAILER_RELATIVE_PATH); + fs.mkdirSync(path.dirname(thumbnailerPath), { recursive: true }); + fs.writeFileSync(thumbnailerPath, content); + return thumbnailerPath; +} + async function withTempDir(fn: (dir: string) => Promise | T): Promise { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-linux-plugin-assets-test-')); try { @@ -48,6 +60,7 @@ test('resolveManagedLinuxRuntimePluginPaths resolves XDG data target paths', () pluginEntrypointPath: '/tmp/xdg-data/SubMiner/plugin/subminer/main.lua', pluginConfigPath: '/tmp/xdg-data/SubMiner/plugin/subminer.conf', themePath: '/tmp/xdg-data/SubMiner/themes/subminer.rasi', + thumbnailerPath: '/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer', }); }); @@ -79,6 +92,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and ro await withTempDir(async (tempDir) => { const sourceRoot = path.join(tempDir, 'source', 'plugin'); const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi'); + const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets')); const targetRoot = path.join(tempDir, 'xdg-data', 'SubMiner', 'plugin'); fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true }); fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true }); @@ -94,6 +108,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and ro pluginDirSource: path.join(sourceRoot, 'subminer'), pluginConfigSource: path.join(sourceRoot, 'subminer.conf'), themeSourcePath, + thumbnailerSourcePath, }), }); @@ -117,6 +132,19 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and ro ), '/* theme */\n', ); + assert.equal( + fs.readFileSync( + path.join( + tempDir, + 'xdg-data', + 'SubMiner', + 'thumbnailers', + 'subminer-ffmpegthumbnailer.thumbnailer', + ), + 'utf8', + ), + '[Thumbnailer Entry]\n', + ); }); }); @@ -124,6 +152,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets a await withTempDir(async (tempDir) => { const sourceRoot = path.join(tempDir, 'source', 'plugin'); const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi'); + const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets')); const xdgDataHome = path.join(tempDir, 'xdg-data'); const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin'); fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true }); @@ -143,6 +172,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets a pluginDirSource: path.join(sourceRoot, 'subminer'), pluginConfigSource: path.join(sourceRoot, 'subminer.conf'), themeSourcePath, + thumbnailerSourcePath, }), }); @@ -169,6 +199,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets a test('ensureLinuxRuntimePluginAssets installs managed theme without resolving plugin sources when plugin assets already exist', async () => { await withTempDir(async (tempDir) => { const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi'); + const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets')); const xdgDataHome = path.join(tempDir, 'xdg-data'); const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin'); fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true }); @@ -183,6 +214,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme without resolving pl xdgDataHome, resolveBundledAssets: () => ({ themeSourcePath, + thumbnailerSourcePath, }), }); @@ -250,6 +282,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin assets without reso path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'), '/* existing theme */\n', ); + const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets')); const result = await ensureLinuxRuntimePluginAssets({ platform: 'linux', @@ -258,6 +291,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin assets without reso resolveBundledAssets: () => ({ pluginDirSource: path.join(sourceRoot, 'subminer'), pluginConfigSource: path.join(sourceRoot, 'subminer.conf'), + thumbnailerSourcePath, }), }); @@ -332,6 +366,7 @@ test('ensureLinuxRuntimePluginAssets returns already-present when managed assets path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'), '/* theme */\n', ); + writeThumbnailer(path.join(xdgDataHome, 'SubMiner')); const result = await ensureLinuxRuntimePluginAssets({ platform: 'linux', @@ -369,6 +404,7 @@ test('ensureLinuxRuntimePluginAssets leaves no final target tree on failed insta await withTempDir(async (tempDir) => { const sourceRoot = path.join(tempDir, 'source', 'plugin'); const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi'); + const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets')); const xdgDataHome = path.join(tempDir, 'xdg-data'); const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin'); fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true }); @@ -385,6 +421,7 @@ test('ensureLinuxRuntimePluginAssets leaves no final target tree on failed insta pluginDirSource: path.join(sourceRoot, 'subminer'), pluginConfigSource: path.join(sourceRoot, 'subminer.conf'), themeSourcePath, + thumbnailerSourcePath, }), copyFile: async () => { throw new Error('copy failed'); diff --git a/src/main/runtime/linux-runtime-plugin-assets.ts b/src/main/runtime/linux-runtime-plugin-assets.ts index 8a638081..48f398cd 100644 --- a/src/main/runtime/linux-runtime-plugin-assets.ts +++ b/src/main/runtime/linux-runtime-plugin-assets.ts @@ -10,6 +10,7 @@ export interface ManagedLinuxRuntimePluginPaths { pluginEntrypointPath: string; pluginConfigPath: string; themePath: string; + thumbnailerPath: string; } export interface EnsureLinuxRuntimePluginAssetsResult { @@ -23,6 +24,7 @@ interface RuntimePluginAssetSources { pluginDirSource?: string; pluginConfigSource?: string; themeSourcePath?: string; + thumbnailerSourcePath?: string; } interface RuntimePluginDirentLike { @@ -72,6 +74,11 @@ export function resolveManagedLinuxRuntimePluginPaths(options: { pluginEntrypointPath: pathModule.join(pluginDir, 'main.lua'), pluginConfigPath: pathModule.join(rootDir, 'subminer.conf'), themePath: pathModule.join(dataDir, 'themes', 'subminer.rasi'), + thumbnailerPath: pathModule.join( + dataDir, + 'thumbnailers', + 'subminer-ffmpegthumbnailer.thumbnailer', + ), }; } @@ -95,11 +102,12 @@ async function copyDirectoryRecursive( } } -function resolveBundledThemePath(options: { +function resolveBundledAssetPath(options: { dirname: string; appPath: string; resourcesPath: string; existsSync: (candidate: string) => boolean; + relativePath: string; }): string | null { const roots = [ path.join(options.resourcesPath, 'assets'), @@ -111,7 +119,7 @@ function resolveBundledThemePath(options: { ]; for (const root of roots) { - const candidate = path.join(root, 'themes', 'subminer.rasi'); + const candidate = path.join(root, options.relativePath); if (options.existsSync(candidate)) return candidate; } @@ -129,16 +137,25 @@ function resolveBundledAssetsDefault( existsSync, }); - const themeSourcePath = resolveBundledThemePath({ + const themeSourcePath = resolveBundledAssetPath({ dirname: __dirname, appPath: process.execPath, resourcesPath, existsSync, + relativePath: path.join('themes', 'subminer.rasi'), + }); + const thumbnailerSourcePath = resolveBundledAssetPath({ + dirname: __dirname, + appPath: process.execPath, + resourcesPath, + existsSync, + relativePath: path.join('thumbnailers', 'subminer-ffmpegthumbnailer.thumbnailer'), }); return { ...(pluginAssets ?? {}), ...(themeSourcePath ? { themeSourcePath } : {}), + ...(thumbnailerSourcePath ? { thumbnailerSourcePath } : {}), }; } @@ -178,7 +195,8 @@ export async function ensureLinuxRuntimePluginAssets( const pluginAssetsExist = existsSync(managedPaths.pluginEntrypointPath) && existsSync(managedPaths.pluginConfigPath); const themeExists = existsSync(managedPaths.themePath); - if (pluginAssetsExist && themeExists) { + const thumbnailerExists = existsSync(managedPaths.thumbnailerPath); + if (pluginAssetsExist && themeExists && thumbnailerExists) { return { ok: true, status: 'already-present', @@ -193,6 +211,7 @@ export async function ensureLinuxRuntimePluginAssets( const shouldInstallPluginAssets = !pluginAssetsExist; const shouldInstallTheme = !themeExists; + const shouldInstallThumbnailer = !thumbnailerExists; if ( shouldInstallPluginAssets && (!bundledAssets.pluginDirSource || !bundledAssets.pluginConfigSource) @@ -210,6 +229,13 @@ export async function ensureLinuxRuntimePluginAssets( error: 'Bundled Linux runtime theme asset was not found.', }; } + if (shouldInstallThumbnailer && !bundledAssets.thumbnailerSourcePath) { + return { + ok: false, + status: 'failed', + error: 'Bundled Linux rofi thumbnailer asset was not found.', + }; + } const stagingSuffix = `${process.pid}-${Date.now()}`; const stagedPluginDir = pathModule.join(managedPaths.rootDir, `.subminer-stage-${stagingSuffix}`); @@ -221,9 +247,14 @@ export async function ensureLinuxRuntimePluginAssets( pathModule.dirname(managedPaths.themePath), `.subminer.rasi-stage-${stagingSuffix}`, ); + const stagedThumbnailerPath = pathModule.join( + pathModule.dirname(managedPaths.thumbnailerPath), + `.subminer-ffmpegthumbnailer.thumbnailer-stage-${stagingSuffix}`, + ); let pluginDirInstalled = false; let pluginConfigInstalled = false; let themeInstalled = false; + let thumbnailerInstalled = false; try { if (shouldInstallPluginAssets) { @@ -249,6 +280,14 @@ export async function ensureLinuxRuntimePluginAssets( await mkdir(pathModule.dirname(managedPaths.themePath), { recursive: true }); await copyFile(themeSourcePath, stagedThemePath); } + if (shouldInstallThumbnailer) { + const thumbnailerSourcePath = bundledAssets.thumbnailerSourcePath; + if (!thumbnailerSourcePath) { + throw new Error('Bundled Linux rofi thumbnailer asset was not found.'); + } + await mkdir(pathModule.dirname(managedPaths.thumbnailerPath), { recursive: true }); + await copyFile(thumbnailerSourcePath, stagedThumbnailerPath); + } if (shouldInstallPluginAssets) { await rm(managedPaths.pluginDir, { recursive: true, force: true }); await rm(managedPaths.pluginConfigPath, { force: true }); @@ -262,6 +301,11 @@ export async function ensureLinuxRuntimePluginAssets( await rename(stagedThemePath, managedPaths.themePath); themeInstalled = true; } + if (shouldInstallThumbnailer) { + await rm(managedPaths.thumbnailerPath, { force: true }); + await rename(stagedThumbnailerPath, managedPaths.thumbnailerPath); + thumbnailerInstalled = true; + } return { ok: true, @@ -278,9 +322,13 @@ export async function ensureLinuxRuntimePluginAssets( if (themeInstalled) { await rm(managedPaths.themePath, { force: true }).catch(() => {}); } + if (thumbnailerInstalled) { + await rm(managedPaths.thumbnailerPath, { force: true }).catch(() => {}); + } await rm(stagedPluginDir, { recursive: true, force: true }).catch(() => {}); await rm(stagedPluginConfigPath, { force: true }).catch(() => {}); await rm(stagedThemePath, { force: true }).catch(() => {}); + await rm(stagedThumbnailerPath, { force: true }).catch(() => {}); return { ok: false, status: 'failed', diff --git a/src/main/runtime/mpv-client-event-bindings.test.ts b/src/main/runtime/mpv-client-event-bindings.test.ts index 74bf63f1..f61e5274 100644 --- a/src/main/runtime/mpv-client-event-bindings.test.ts +++ b/src/main/runtime/mpv-client-event-bindings.test.ts @@ -191,6 +191,8 @@ test('mpv event bindings register all expected events', () => { onSubtitleAssChange: () => {}, onSecondarySubtitleChange: () => {}, onSubtitleTrackChange: () => {}, + onSecondarySubtitleTrackChange: () => {}, + onSecondarySubtitleDelayChange: () => {}, onSubtitleTrackListChange: () => {}, onSubtitleTiming: () => {}, onMediaPathChange: () => {}, @@ -215,6 +217,8 @@ test('mpv event bindings register all expected events', () => { 'subtitle-ass-change', 'secondary-subtitle-change', 'subtitle-track-change', + 'secondary-subtitle-track-change', + 'secondary-subtitle-delay-change', 'subtitle-track-list-change', 'subtitle-timing', 'media-path-change', diff --git a/src/main/runtime/mpv-client-event-bindings.ts b/src/main/runtime/mpv-client-event-bindings.ts index 8132c69a..cb0d7c83 100644 --- a/src/main/runtime/mpv-client-event-bindings.ts +++ b/src/main/runtime/mpv-client-event-bindings.ts @@ -4,6 +4,8 @@ type MpvBindingEventName = | 'subtitle-ass-change' | 'secondary-subtitle-change' | 'subtitle-track-change' + | 'secondary-subtitle-track-change' + | 'secondary-subtitle-delay-change' | 'subtitle-track-list-change' | 'subtitle-timing' | 'media-path-change' @@ -90,6 +92,8 @@ export function createBindMpvClientEventHandlers(deps: { onSubtitleAssChange: (payload: { text: string }) => void; onSecondarySubtitleChange: (payload: { text: string }) => void; onSubtitleTrackChange: (payload: { sid: number | null }) => void; + onSecondarySubtitleTrackChange: (payload: { sid: number | null }) => void; + onSecondarySubtitleDelayChange: (payload: { delay: number }) => void; onSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void; onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void; onMediaPathChange: (payload: { path: string | null }) => void; @@ -107,6 +111,8 @@ export function createBindMpvClientEventHandlers(deps: { mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange); mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange); mpvClient.on('subtitle-track-change', deps.onSubtitleTrackChange); + mpvClient.on('secondary-subtitle-track-change', deps.onSecondarySubtitleTrackChange); + mpvClient.on('secondary-subtitle-delay-change', deps.onSecondarySubtitleDelayChange); mpvClient.on('subtitle-track-list-change', deps.onSubtitleTrackListChange); mpvClient.on('subtitle-timing', deps.onSubtitleTiming); mpvClient.on('media-path-change', deps.onMediaPathChange); diff --git a/src/main/runtime/mpv-main-event-actions.test.ts b/src/main/runtime/mpv-main-event-actions.test.ts index 845a7f08..a7667143 100644 --- a/src/main/runtime/mpv-main-event-actions.test.ts +++ b/src/main/runtime/mpv-main-event-actions.test.ts @@ -26,6 +26,29 @@ test('subtitle change handler updates state and forwards uncached text without r assert.deepEqual(calls, ['set:line', 'process:line', 'presence']); }); +test('subtitle change handler consistently forwards resolved canonical text', () => { + const calls: string[] = []; + const handler = createHandleMpvSubtitleChangeHandler({ + resolveSubtitleText: () => '今 手にある物差しでは', + setCurrentSubText: (text) => calls.push(`set:${text}`), + getImmediateSubtitlePayload: (text) => { + calls.push(`lookup:${text}`); + return null; + }, + broadcastSubtitle: () => {}, + onSubtitleChange: (text) => calls.push(`process:${text}`), + refreshDiscordPresence: () => {}, + }); + + handler({ text: '今今今手手手ににに' }); + + assert.deepEqual(calls, [ + 'set:今 手にある物差しでは', + 'lookup:今 手にある物差しでは', + 'process:今 手にある物差しでは', + ]); +}); + test('subtitle change handler clears immediately for empty subtitle text', () => { const calls: string[] = []; const handler = createHandleMpvSubtitleChangeHandler({ @@ -335,6 +358,30 @@ test('time-pos handler forces Jellyfin progress when mpv position jumps', () => ]); }); +test('time-pos handler treats an explicit short jump as a seek', () => { + const updateKinds: string[] = []; + let explicitSeekPending = false; + const timeHandler = createHandleMpvTimePosChangeHandler({ + recordPlaybackPosition: () => {}, + reportJellyfinRemoteProgress: () => {}, + refreshDiscordPresence: () => {}, + maybeRunAnilistPostWatchUpdate: async () => {}, + consumeExplicitSeek: () => { + const pending = explicitSeekPending; + explicitSeekPending = false; + return pending; + }, + onTimePosUpdate: (_time, kind) => updateKinds.push(kind), + }); + + timeHandler({ time: 10 }); + explicitSeekPending = true; + timeHandler({ time: 11.5 }); + timeHandler({ time: 11.6 }); + + assert.deepEqual(updateKinds, ['initial', 'seek', 'playback']); +}); + test('time-pos handler passes fresh playback time to AniList post-watch', async () => { const watchedSeconds: unknown[] = []; const timeHandler = createHandleMpvTimePosChangeHandler({ diff --git a/src/main/runtime/mpv-main-event-actions.ts b/src/main/runtime/mpv-main-event-actions.ts index 7f38e596..ab0b4577 100644 --- a/src/main/runtime/mpv-main-event-actions.ts +++ b/src/main/runtime/mpv-main-event-actions.ts @@ -4,7 +4,10 @@ type AnilistPostWatchRunOptions = { watchedSeconds?: number; }; -const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5; +type TimePosUpdateKind = 'initial' | 'playback' | 'seek'; + +/** Jump size that marks a time-pos change as a seek rather than normal playback. */ +export const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5; function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): boolean { if (previousTime === null || !Number.isFinite(previousTime) || !Number.isFinite(nextTime)) { @@ -14,6 +17,7 @@ function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): bo } export function createHandleMpvSubtitleChangeHandler(deps: { + resolveSubtitleText?: (text: string) => string; setCurrentSubText: (text: string) => void; getImmediateSubtitlePayload?: (text: string) => SubtitleData | null; emitImmediateSubtitle?: (payload: SubtitleData) => void; @@ -22,7 +26,8 @@ export function createHandleMpvSubtitleChangeHandler(deps: { refreshDiscordPresence: () => void; logDebug?: (message: string) => void; }) { - return ({ text }: { text: string }): void => { + return ({ text: liveText }: { text: string }): void => { + const text = deps.resolveSubtitleText?.(liveText) ?? liveText; deps.setCurrentSubText(text); const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null; if (immediatePayload) { @@ -135,12 +140,20 @@ export function createHandleMpvTimePosChangeHandler(deps: { refreshDiscordPresence: () => void; maybeRunAnilistPostWatchUpdate?: (options?: AnilistPostWatchRunOptions) => Promise; logError?: (message: string, error: unknown) => void; - onTimePosUpdate?: (time: number) => void; + onTimePosUpdate?: (time: number, kind: TimePosUpdateKind) => void; + consumeExplicitSeek?: () => boolean; }) { let lastObservedTime: number | null = null; return ({ time }: { time: number }): void => { - const forceImmediate = isSeekLikeTimeChange(lastObservedTime, time); + const explicitSeek = deps.consumeExplicitSeek?.() ?? false; + const updateKind: TimePosUpdateKind = + lastObservedTime === null + ? 'initial' + : explicitSeek || isSeekLikeTimeChange(lastObservedTime, time) + ? 'seek' + : 'playback'; + const forceImmediate = updateKind === 'seek'; if (Number.isFinite(time)) { lastObservedTime = time; } @@ -150,7 +163,7 @@ export function createHandleMpvTimePosChangeHandler(deps: { void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => { deps.logError?.('AniList post-watch update failed unexpectedly', error); }); - deps.onTimePosUpdate?.(time); + deps.onTimePosUpdate?.(time, updateKind); }; } diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts index 570ab696..82133f71 100644 --- a/src/main/runtime/mpv-main-event-bindings.test.ts +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -1,10 +1,23 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser'; import { createBindMpvMainEventHandlersHandler } from './mpv-main-event-bindings'; +import { resolvePrimarySubtitleText } from './primary-subtitle-text'; test('main mpv event binder wires callbacks through to runtime deps', () => { const handlers = new Map void>(); const calls: string[] = []; + let currentTime = 0; + const seekLiveText = '少しだけ好きになる\n少しだけ好きになる'; + const seekCues = parseSubtitleCues( + [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 1,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,少しだけ好きになる', + 'Dialogue: 0,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,少しだけ好きになる', + ].join('\n'), + 'seek-ending.ass', + ); const bind = createBindMpvMainEventHandlersHandler({ reportJellyfinRemoteStopped: () => calls.push('remote-stopped'), @@ -27,6 +40,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { calls.push(`post-watch:${options?.watchedSeconds ?? 'none'}`); }, logSubtitleTimingError: () => calls.push('subtitle-error'), + resolveSubtitleText: (liveText) => + resolvePrimarySubtitleText({ liveText, currentTimeSec: currentTime, cues: seekCues }), + getCurrentLiveSubtitleText: () => seekLiveText, setCurrentSubText: (text) => calls.push(`set-sub:${text}`), getImmediateSubtitlePayload: (text) => ({ text, tokens: [] }), broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`), @@ -37,6 +53,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`), broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`), onSubtitleTrackChange: () => calls.push('subtitle-track-change'), + onSecondarySubtitleTrackChange: () => calls.push('secondary-subtitle-track-change'), + onSecondarySubtitleDelayChange: (delay) => + calls.push(`secondary-subtitle-delay-change:${delay}`), onSubtitleTrackListChange: () => calls.push('subtitle-track-list-change'), updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`), @@ -57,6 +76,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { recordMediaDuration: (duration) => calls.push(`duration:${duration}`), reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate ? 'force' : 'normal'}`), + onTimePosUpdate: (time) => { + currentTime = time; + }, recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`), updateSubtitleRenderMetrics: () => calls.push('subtitle-metrics'), @@ -73,12 +95,20 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { handlers.get('connection-change')?.({ connected: true }); handlers.get('subtitle-change')?.({ text: 'line' }); handlers.get('subtitle-track-change')?.({ sid: 3 }); + handlers.get('secondary-subtitle-track-change')?.({ sid: 4 }); + handlers.get('secondary-subtitle-delay-change')?.({ delay: 0.5 }); handlers.get('subtitle-track-list-change')?.({ trackList: [] }); handlers.get('media-path-change')?.({ path: '/tmp/video.mkv' }); handlers.get('media-path-change')?.({ path: '' }); handlers.get('media-title-change')?.({ title: 'Episode 1' }); handlers.get('subtitle-timing')?.({ text: 'timed line', start: 899, end: 901 }); + handlers.get('subtitle-change')?.({ text: seekLiveText }); + handlers.get('time-pos-change')?.({ time: 90 }); + assert.ok(calls.includes('set-sub:少しだけ好きになる')); + handlers.get('time-pos-change')?.({ time: 2.5 }); + handlers.get('subtitle-change')?.({ text: seekLiveText }); + handlers.get('time-pos-change')?.({ time: 90 }); handlers.get('pause-change')?.({ paused: true }); assert.ok(calls.includes('set-sub:line')); @@ -86,6 +116,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { assert.equal(calls.includes('broadcast-sub:line'), true); assert.ok(calls.includes('subtitle-change:line')); assert.ok(calls.includes('subtitle-track-change')); + assert.ok(calls.includes('secondary-subtitle-track-change')); + assert.ok(calls.includes('secondary-subtitle-delay-change:0.5')); assert.ok(calls.includes('subtitle-track-list-change')); assert.ok(calls.includes('media-title:Episode 1')); assert.ok(calls.includes('media-path:/tmp/video.mkv')); diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index 4fcbeba9..f4eee996 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -43,6 +43,8 @@ export function createBindMpvMainEventHandlersHandler(deps: { logSubtitleTimingError: (message: string, error: unknown) => void; setCurrentSubText: (text: string) => void; + resolveSubtitleText?: (text: string) => string; + getCurrentLiveSubtitleText?: () => string; getImmediateSubtitlePayload?: (text: string) => SubtitleData | null; emitImmediateSubtitle?: (payload: SubtitleData) => void; broadcastSubtitle: (payload: SubtitleData) => void; @@ -54,6 +56,8 @@ export function createBindMpvMainEventHandlersHandler(deps: { broadcastSubtitleAss: (text: string) => void; broadcastSecondarySubtitle: (text: string) => void; onSubtitleTrackChange?: (sid: number | null) => void; + onSecondarySubtitleTrackChange?: (sid: number | null) => void; + onSecondarySubtitleDelayChange?: (delay: number) => void; onSubtitleTrackListChange?: (trackList: unknown[] | null) => void; updateCurrentMediaPath: (path: string) => void; @@ -75,6 +79,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { recordMediaDuration: (durationSec: number) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; onTimePosUpdate?: (time: number) => void; + consumeExplicitSeek?: () => boolean; onFullscreenChange?: (fullscreen: boolean) => void; recordPauseState: (paused: boolean) => void; @@ -117,6 +122,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { logError: (message, error) => deps.logSubtitleTimingError(message, error), }); const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({ + resolveSubtitleText: deps.resolveSubtitleText, setCurrentSubText: (text) => deps.setCurrentSubText(text), getImmediateSubtitlePayload: (text) => deps.getImmediateSubtitlePayload?.(text) ?? null, emitImmediateSubtitle: deps.emitImmediateSubtitle @@ -167,7 +173,15 @@ export function createBindMpvMainEventHandlersHandler(deps: { refreshDiscordPresence: () => deps.refreshDiscordPresence(), maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options), logError: (message, error) => deps.logSubtitleTimingError(message, error), - onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time), + consumeExplicitSeek: deps.consumeExplicitSeek, + onTimePosUpdate: (time, updateKind) => { + deps.onTimePosUpdate?.(time); + if (updateKind === 'playback') return; + const liveText = deps.getCurrentLiveSubtitleText?.(); + if (liveText !== undefined) { + handleMpvSubtitleChange({ text: liveText }); + } + }, }); const handleMpvPauseChange = createHandleMpvPauseChangeHandler({ recordPauseState: (paused) => deps.recordPauseState(paused), @@ -189,6 +203,8 @@ export function createBindMpvMainEventHandlersHandler(deps: { onSubtitleAssChange: handleMpvSubtitleAssChange, onSecondarySubtitleChange: handleMpvSecondarySubtitleChange, onSubtitleTrackChange: ({ sid }) => deps.onSubtitleTrackChange?.(sid), + onSecondarySubtitleTrackChange: ({ sid }) => deps.onSecondarySubtitleTrackChange?.(sid), + onSecondarySubtitleDelayChange: ({ delay }) => deps.onSecondarySubtitleDelayChange?.(delay), onSubtitleTrackListChange: ({ trackList }) => deps.onSubtitleTrackListChange?.(trackList), onSubtitleTiming: handleMpvSubtitleTiming, onMediaPathChange: handleMpvMediaPathChange, diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index 238e21c3..ba46884a 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -47,6 +47,9 @@ test('mpv main event main deps map app state updates and delegate callbacks', as logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`), broadcastToOverlayWindows: (channel, payload) => calls.push(`broadcast:${channel}:${String(payload)}`), + onSecondarySubtitleChange: (text) => calls.push(`secondary:${text}`), + onSecondarySubtitleTrackChange: (sid) => calls.push(`secondary-track:${String(sid)}`), + onSecondarySubtitleDelayChange: (delay) => calls.push(`secondary-delay:${delay}`), onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`), ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'), updateCurrentMediaPath: (path) => calls.push(`path:${path}`), @@ -86,6 +89,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as deps.setCurrentSubAssText('ass'); deps.broadcastSubtitleAss('ass'); deps.broadcastSecondarySubtitle('sec'); + deps.onSecondarySubtitleTrackChange?.(4); + deps.onSecondarySubtitleDelayChange?.(0.5); deps.updateCurrentMediaPath('/tmp/video'); deps.restoreMpvSubVisibility(); deps.resetSubtitleSidebarEmbeddedLayout(); @@ -116,6 +121,10 @@ test('mpv main event main deps map app state updates and delegate callbacks', as assert.ok(calls.includes('sync-overlay-mpv-sub')); assert.ok(calls.includes('anilist-post-watch')); assert.ok(calls.includes('timing:y:secondary')); + assert.ok(calls.includes('secondary:sec')); + assert.ok(calls.includes('secondary-track:4')); + assert.ok(calls.includes('secondary-delay:0.5')); + assert.ok(!calls.includes('broadcast:secondary-subtitle:set:sec')); assert.ok(calls.includes('ensure-immersion')); assert.ok(calls.includes('sync-immersion')); assert.ok(calls.includes('autoplay:/tmp/video')); @@ -387,3 +396,182 @@ test('subtitle-track transitions ignore stale parsed cues until replacement cues handlers.recordImmersionSubtitleLine('飛び上がる', 20.04, 20.08); assert.deepEqual(recordedStarts.slice(-1), [20]); }); + +test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => { + const immersion: Array<{ text: string; start: number; end: number }> = []; + const timing: Array<{ text: string; start: number; end: number }> = []; + const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({ + appState: { + initialArgs: null, + overlayRuntimeInitialized: true, + mpvClient: { currentTimePos: 2 }, + immersionTracker: { + recordSubtitleLine: (text: string, start: number, end: number) => + immersion.push({ text, start, end }), + }, + subtitleTimingTracker: { + recordSubtitle: (text: string, start: number, end: number) => + timing.push({ text, start, end }), + }, + activeParsedSubtitleCues: [ + { + startTime: 1.2, + endTime: 3.8, + text: '今 手にある物差しでは', + source: 'canonical-ass', + }, + { + startTime: 3, + endTime: 6, + text: '飛び越えてみたくて', + source: 'canonical-ass', + }, + { + startTime: 10, + endTime: 12, + text: 'MaidCafeMaidCafe', + source: 'reconstructed-ass', + assLayout: { kind: 'fragment-grid', sourceOrder: 2 }, + }, + ], + currentMediaPath: '/video.mkv', + currentSubText: '', + currentSubAssText: '', + playbackPaused: null, + previousSecondarySubVisibility: false, + }, + getQuitOnDisconnectArmed: () => false, + scheduleQuitCheck: () => {}, + quitApp: () => {}, + reportJellyfinRemoteStopped: () => {}, + syncOverlayMpvSubtitleSuppression: () => {}, + maybeRunAnilistPostWatchUpdate: async () => {}, + logSubtitleTimingError: () => {}, + broadcastToOverlayWindows: () => {}, + onSubtitleChange: () => {}, + ensureImmersionTrackerInitialized: () => {}, + updateCurrentMediaPath: () => {}, + restoreMpvSubVisibility: () => {}, + getCurrentAnilistMediaKey: () => null, + resetAnilistMediaTracking: () => {}, + maybeProbeAnilistDuration: () => {}, + ensureAnilistMediaGuess: () => {}, + syncImmersionMediaState: () => {}, + updateCurrentMediaTitle: () => {}, + resetAnilistMediaGuessState: () => {}, + reportJellyfinRemoteProgress: () => {}, + updateSubtitleRenderMetrics: () => {}, + refreshDiscordPresence: () => {}, + })(); + + assert.equal(handlers.resolveSubtitleText?.('今\n今\n今\n手\n手\n手'), '今 手にある物差しでは'); + handlers.recordImmersionSubtitleLine('今', 0.8, 1.5); + handlers.recordImmersionSubtitleLine('手', 0.86, 1.56); + handlers.recordSubtitleTiming('今', 0.8, 1.5); + + assert.deepEqual(immersion, [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]); + assert.deepEqual(timing, [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]); + + // Concurrent dialogue during the song is not part of the animation: it must be + // recorded as itself -- without the fragment lines beside it -- and must not cause + // the song line to be recorded again when the animation frames resume. + assert.equal(handlers.resolveSubtitleText?.('普通のセリフ\n今\n手'), '普通のセリフ\n今\n手'); + handlers.recordImmersionSubtitleLine('普通のセリフ\n今\n手', 1.9, 3.2); + handlers.recordImmersionSubtitleLine('にある', 2.1, 2.9); + handlers.recordSubtitleTiming('次のセリフ', 3.9, 5.0); + + assert.deepEqual(immersion.slice(1), [{ text: '普通のセリフ', start: 1.9, end: 3.2 }]); + assert.deepEqual(timing.slice(1), [{ text: '次のセリフ', start: 3.9, end: 5 }]); + + // Overlapping canonical lines resolve as shifting subsets (A, then A+B, then A). + // Every recorded cue is remembered, so each authored line still records exactly once. + handlers.recordImmersionSubtitleLine('飛び越えて', 3.2, 3.4); + handlers.recordImmersionSubtitleLine('手にある', 3.5, 3.7); + handlers.recordSubtitleTiming('飛び越えて', 3.2, 3.4); + handlers.recordSubtitleTiming('手にある', 3.5, 3.7); + + assert.deepEqual(immersion.slice(2), [{ text: '飛び越えてみたくて', start: 3, end: 6 }]); + assert.deepEqual(timing.slice(2), [{ text: '飛び越えてみたくて', start: 3, end: 6 }]); + + // A backward seek means the user is rewatching: the timing history (a viewing log) + // records the revisited line again, while immersion stays once-per-media. + handlers.onTimePosUpdate?.(30); + handlers.onTimePosUpdate?.(2); + handlers.recordSubtitleTiming('今', 0.8, 1.5); + handlers.recordImmersionSubtitleLine('今', 0.8, 1.5); + + assert.deepEqual(timing.slice(3), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]); + assert.equal(immersion.length, 3); + + // A jump of exactly the seek threshold counts as a seek, matching the time-pos + // handler's own `>=` boundary. + handlers.onTimePosUpdate?.(4.5); + handlers.onTimePosUpdate?.(2); + handlers.recordSubtitleTiming('今', 0.8, 1.5); + + assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]); + + handlers.recordImmersionSubtitleLine('Maid\nCafe', 10, 12); + handlers.recordSubtitleTiming('Maid\nCafe', 10, 12); + assert.equal(immersion.length, 3); + assert.equal(timing.length, 5); +}); + +test('subtitle-track changes stop stale canonical cues from substituting immediately', () => { + const appState = { + initialArgs: null, + overlayRuntimeInitialized: true, + mpvClient: { currentTimePos: 2 }, + immersionTracker: { recordSubtitleLine: () => {} }, + subtitleTimingTracker: { recordSubtitle: () => {} }, + activeParsedSubtitleCues: [ + { + startTime: 1.2, + endTime: 3.8, + text: '今 手にある物差しでは', + source: 'canonical-ass' as const, + }, + ] as Array<{ startTime: number; endTime: number; text: string; source?: 'canonical-ass' }>, + activeParsedSubtitleSource: 'track-a.ass' as string | null, + currentMediaPath: '/video.mkv', + currentSubText: '', + currentSubAssText: '', + playbackPaused: null, + previousSecondarySubVisibility: false, + }; + const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({ + appState, + getQuitOnDisconnectArmed: () => false, + scheduleQuitCheck: () => {}, + quitApp: () => {}, + reportJellyfinRemoteStopped: () => {}, + syncOverlayMpvSubtitleSuppression: () => {}, + maybeRunAnilistPostWatchUpdate: async () => {}, + logSubtitleTimingError: () => {}, + broadcastToOverlayWindows: () => {}, + onSubtitleChange: () => {}, + ensureImmersionTrackerInitialized: () => {}, + updateCurrentMediaPath: () => {}, + restoreMpvSubVisibility: () => {}, + getCurrentAnilistMediaKey: () => null, + resetAnilistMediaTracking: () => {}, + maybeProbeAnilistDuration: () => {}, + ensureAnilistMediaGuess: () => {}, + syncImmersionMediaState: () => {}, + updateCurrentMediaTitle: () => {}, + resetAnilistMediaGuessState: () => {}, + reportJellyfinRemoteProgress: () => {}, + updateSubtitleRenderMetrics: () => {}, + refreshDiscordPresence: () => {}, + })(); + + assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今 手にある物差しでは'); + + // The new track's cues arrive only after an async re-parse; until then, the old + // track's canonical lyric must not replace the new track's live text. + handlers.onSubtitleTrackChange?.(2); + + assert.deepEqual(appState.activeParsedSubtitleCues, []); + assert.equal(appState.activeParsedSubtitleSource, null); + assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今\n手にある'); +}); diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 5acd36a2..5d87be61 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -1,5 +1,11 @@ import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate'; import type { MergedToken, SubtitleCue, SubtitleData } from '../../types'; +import { SEEK_LIKE_TIME_DELTA_SECONDS } from './mpv-main-event-actions'; +import { + resolveCanonicalPrimarySubtitle, + resolvePrimarySubtitleText, + stripCanonicalFragmentLines, +} from './primary-subtitle-text'; type AnilistPostWatchRunOptions = { watchedSeconds?: number; @@ -15,6 +21,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { overlayRuntimeInitialized: boolean; mpvClient: { connected?: boolean; + currentSubText?: string; currentSecondarySubText?: string; currentTimePos?: number; requestProperty?: (name: string) => Promise; @@ -36,6 +43,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { recordSubtitle?: (text: string, start: number, end: number, secondaryText?: string) => void; } | null; activeParsedSubtitleCues?: SubtitleCue[] | null; + /** Cache key of the source the cues were parsed from; cleared with the cues. */ + activeParsedSubtitleSource?: string | null; currentMediaPath?: string | null; currentSubText: string; currentSubAssText: string; @@ -53,11 +62,14 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { recordAnilistMediaDuration?: (durationSec: number) => void; logSubtitleTimingError: (message: string, error: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void; + onSecondarySubtitleChange?: (text: string) => void; getImmediateSubtitlePayload?: (text: string) => SubtitleData | null; emitImmediateSubtitle?: (payload: SubtitleData) => void; onSubtitleChange: (text: string) => void; logSubtitleProcessingDebug?: (message: string) => void; onSubtitleTrackChange?: (sid: number | null) => void; + onSecondarySubtitleTrackChange?: (sid: number | null) => void; + onSecondarySubtitleDelayChange?: (delay: number) => void; onSubtitleTrackListChange?: (trackList: unknown[] | null) => void; updateCurrentMediaPath: (path: string) => void; restoreMpvSubVisibility: () => void; @@ -74,6 +86,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { resetAnilistMediaGuessState: () => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; onTimePosUpdate?: (time: number) => void; + consumeExplicitSeek?: () => boolean; onFullscreenChange?: (fullscreen: boolean) => void; updateSubtitleRenderMetrics: (patch: Record) => void; refreshDiscordPresence: () => void; @@ -93,6 +106,38 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { const immersionLineDedupGate = createSubtitleLineDedupGate({ getParsedCues: () => deps.appState.activeParsedSubtitleCues, }); + // One seen-set per consumer: canonical cues overlap, so live samples resolve to + // shifting subsets (A, then A+B, then B). Remembering every recorded cue -- not just + // the previous sample -- keeps each authored line recorded exactly once per source. + const recordedImmersionCanonicalKeys = new Set(); + const recordedTimingCanonicalKeys = new Set(); + // Bumped on track/media changes so an immersion record whose tokenization resolves + // after the change is dropped instead of landing in the next session. + let subtitleSessionEpoch = 0; + let lastTimePosForTimingReset: number | null = null; + const canonicalCueKey = (cue: SubtitleCue): string => + `${cue.startTime}|${cue.endTime}|${cue.text}`; + const resetSubtitleDeduplication = (): void => { + immersionLineDedupGate.reset(); + recordedImmersionCanonicalKeys.clear(); + recordedTimingCanonicalKeys.clear(); + subtitleSessionEpoch += 1; + lastTimePosForTimingReset = null; + }; + const resolveCanonicalSample = (liveText: string, startSec: number) => + resolveCanonicalPrimarySubtitle({ + liveText, + currentTimeSec: startSec, + cues: deps.appState.activeParsedSubtitleCues, + }); + // When substitution declined because dialogue shares the screen with a song, record + // the dialogue alone rather than the combined dialogue-plus-fragments stack. + const stripFragmentsForRecording = (liveText: string, startSec: number) => + stripCanonicalFragmentLines({ + liveText, + currentTimeSec: startSec, + cues: deps.appState.activeParsedSubtitleCues, + }); const hasInitialPlaybackQuitOnDisconnectArg = (): boolean => Boolean( deps.appState.initialArgs?.managedPlayback || @@ -111,45 +156,107 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback), isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected), quitApp: () => deps.quitApp(), + resolveSubtitleText: (liveText: string) => + resolvePrimarySubtitleText({ + liveText, + currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos), + cues: deps.appState.activeParsedSubtitleCues, + }), + getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '', recordImmersionSubtitleLine: (text: string, start: number, end: number) => { deps.ensureImmersionTrackerInitialized(); const tracker = deps.appState.immersionTracker; if (!tracker?.recordSubtitleLine) { return; } + const recordLine = (lineText: string, startSec: number, endSec: number): void => { + const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null; + const cachedTokens = + deps.appState.currentSubtitleData?.text === lineText + ? deps.appState.currentSubtitleData.tokens + : null; + if (cachedTokens) { + tracker.recordSubtitleLine?.(lineText, startSec, endSec, cachedTokens, secondaryText); + return; + } + if (!deps.tokenizeSubtitleForImmersion) { + tracker.recordSubtitleLine?.(lineText, startSec, endSec, null, secondaryText); + return; + } + const epochAtRecord = subtitleSessionEpoch; + void deps + .tokenizeSubtitleForImmersion(lineText) + .then((payload) => { + if (subtitleSessionEpoch !== epochAtRecord) { + return; + } + tracker.recordSubtitleLine?.( + lineText, + startSec, + endSec, + payload?.tokens ?? null, + secondaryText, + ); + }) + .catch(() => { + if (subtitleSessionEpoch !== epochAtRecord) { + return; + } + tracker.recordSubtitleLine?.(lineText, startSec, endSec, null, secondaryText); + }); + }; + const canonical = resolveCanonicalSample(text, start); + if (canonical) { + for (const cue of canonical.cues) { + const key = canonicalCueKey(cue); + if (recordedImmersionCanonicalKeys.has(key)) { + continue; + } + recordedImmersionCanonicalKeys.add(key); + recordLine(cue.text, cue.startTime, cue.endTime); + } + return; + } + text = stripFragmentsForRecording(text, start); + if (!text.trim()) { + return; + } if (!immersionLineDedupGate.shouldRecord({ text, startSec: start, endSec: end })) { return; } - const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null; - const cachedTokens = - deps.appState.currentSubtitleData?.text === text - ? deps.appState.currentSubtitleData.tokens - : null; - if (cachedTokens) { - tracker.recordSubtitleLine(text, start, end, cachedTokens, secondaryText); - return; - } - if (!deps.tokenizeSubtitleForImmersion) { - tracker.recordSubtitleLine(text, start, end, null, secondaryText); - return; - } - void deps - .tokenizeSubtitleForImmersion(text) - .then((payload) => { - tracker.recordSubtitleLine?.(text, start, end, payload?.tokens ?? null, secondaryText); - }) - .catch(() => { - tracker.recordSubtitleLine?.(text, start, end, null, secondaryText); - }); + recordLine(text, start, end); }, hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker), - recordSubtitleTiming: (text: string, start: number, end: number) => - deps.appState.subtitleTimingTracker?.recordSubtitle?.( - text, - start, - end, - deps.appState.mpvClient?.currentSecondarySubText || undefined, - ), + recordSubtitleTiming: (text: string, start: number, end: number) => { + const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined; + const canonical = resolveCanonicalSample(text, start); + if (!canonical) { + const recordableText = stripFragmentsForRecording(text, start); + if (!recordableText.trim()) { + return; + } + deps.appState.subtitleTimingTracker?.recordSubtitle?.( + recordableText, + start, + end, + secondaryText, + ); + return; + } + for (const cue of canonical.cues) { + const key = canonicalCueKey(cue); + if (recordedTimingCanonicalKeys.has(key)) { + continue; + } + recordedTimingCanonicalKeys.add(key); + deps.appState.subtitleTimingTracker?.recordSubtitle?.( + cue.text, + cue.startTime, + cue.endTime, + secondaryText, + ); + } + }, maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => deps.maybeRunAnilistPostWatchUpdate(options), logSubtitleTimingError: (message: string, error: unknown) => @@ -170,9 +277,22 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { ? (message: string) => deps.logSubtitleProcessingDebug!(message) : undefined, onSubtitleTrackChange: (sid: number | null) => { - immersionLineDedupGate.reset(); + resetSubtitleDeduplication(); + // The replacement track's cues arrive only after an async re-read and re-parse. + // Clearing synchronously keeps the previous track's canonical cues from + // substituting into, or recording against, the new track's live text. The source + // key is cleared with the cues so cue-list consumers (the sidebar snapshot) + // re-parse on demand instead of trusting the stale pairing. + deps.appState.activeParsedSubtitleCues = []; + deps.appState.activeParsedSubtitleSource = null; deps.onSubtitleTrackChange?.(sid); }, + onSecondarySubtitleTrackChange: deps.onSecondarySubtitleTrackChange + ? (sid: number | null) => deps.onSecondarySubtitleTrackChange!(sid) + : undefined, + onSecondarySubtitleDelayChange: deps.onSecondarySubtitleDelayChange + ? (delay: number) => deps.onSecondarySubtitleDelayChange!(delay) + : undefined, onSubtitleTrackListChange: deps.onSubtitleTrackListChange ? (trackList: unknown[] | null) => deps.onSubtitleTrackListChange!(trackList) : undefined, @@ -182,10 +302,15 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { }, broadcastSubtitleAss: (text: string) => deps.broadcastToOverlayWindows('subtitle-ass:set', text), - broadcastSecondarySubtitle: (text: string) => - deps.broadcastToOverlayWindows('secondary-subtitle:set', text), + broadcastSecondarySubtitle: (text: string) => { + if (deps.onSecondarySubtitleChange) { + deps.onSecondarySubtitleChange(text); + return; + } + deps.broadcastToOverlayWindows('secondary-subtitle:set', text); + }, updateCurrentMediaPath: (path: string) => { - immersionLineDedupGate.reset(); + resetSubtitleDeduplication(); deps.updateCurrentMediaPath(path); }, restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(), @@ -217,9 +342,23 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { }, reportJellyfinRemoteProgress: (forceImmediate: boolean) => deps.reportJellyfinRemoteProgress(forceImmediate), - onTimePosUpdate: deps.onTimePosUpdate - ? (time: number) => deps.onTimePosUpdate!(time) - : undefined, + consumeExplicitSeek: deps.consumeExplicitSeek, + onTimePosUpdate: (time: number) => { + // Timing history is a viewing log: after a real backward seek, a rewatched + // canonical line should enter it again. Immersion stats keep their + // once-per-media deduplication and are not reset here. + if ( + Number.isFinite(time) && + lastTimePosForTimingReset !== null && + time <= lastTimePosForTimingReset - SEEK_LIKE_TIME_DELTA_SECONDS + ) { + recordedTimingCanonicalKeys.clear(); + } + if (Number.isFinite(time)) { + lastTimePosForTimingReset = time; + } + deps.onTimePosUpdate?.(time); + }, onFullscreenChange: deps.onFullscreenChange ? (fullscreen: boolean) => deps.onFullscreenChange!(fullscreen) : undefined, diff --git a/src/main/runtime/network-media-path.test.ts b/src/main/runtime/network-media-path.test.ts new file mode 100644 index 00000000..002667f1 --- /dev/null +++ b/src/main/runtime/network-media-path.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createRemoteMediaPathDetector } from './network-media-path'; + +test('remote media detector recognizes mounted network filesystems', async () => { + const detectRemoteMedia = createRemoteMediaPathDetector({ + platform: 'darwin', + readMountOutput: async () => + [ + '/dev/disk3s5 on /System/Volumes/Data (apfs, local, journaled)', + '//viewer@media/jellyfin on /Volumes/jellyfin (smbfs, nodev, nosuid)', + ].join('\n'), + }); + + assert.equal(await detectRemoteMedia('/Volumes/jellyfin/movie.mkv'), true); + assert.equal(await detectRemoteMedia('/Volumes/jellyfin-another/movie.mkv'), false); + assert.equal(await detectRemoteMedia('/Users/viewer/movie.mkv'), false); +}); + +test('remote media detector recognizes Linux network mount output', async () => { + const detectRemoteMedia = createRemoteMediaPathDetector({ + platform: 'linux', + readMountOutput: async () => + '//media/jellyfin on /mnt/Jellyfin\\040Media type cifs (rw,relatime)', + }); + + assert.equal(await detectRemoteMedia('/mnt/Jellyfin Media/movie.mkv'), true); +}); + +test('remote media detector shares its mount lookup between concurrent callers', async () => { + let mountReads = 0; + const detectRemoteMedia = createRemoteMediaPathDetector({ + platform: 'darwin', + readMountOutput: async () => { + mountReads += 1; + return '//viewer@media/jellyfin on /Volumes/jellyfin (smbfs, nodev, nosuid)'; + }, + }); + + const results = await Promise.all( + Array.from({ length: 6 }, () => detectRemoteMedia('/Volumes/jellyfin/movie.mkv')), + ); + + assert.deepEqual( + results, + Array.from({ length: 6 }, () => true), + ); + assert.equal(mountReads, 1); +}); + +test('remote media detector recognizes URLs and Windows UNC paths without reading mounts', async () => { + let mountReads = 0; + const detectRemoteMedia = createRemoteMediaPathDetector({ + platform: 'win32', + readMountOutput: async () => { + mountReads += 1; + return ''; + }, + }); + + assert.equal(await detectRemoteMedia('https://media.example/movie.mkv'), true); + assert.equal(await detectRemoteMedia('\\\\media-server\\jellyfin\\movie.mkv'), true); + assert.equal(mountReads, 0); +}); diff --git a/src/main/runtime/network-media-path.ts b/src/main/runtime/network-media-path.ts new file mode 100644 index 00000000..9f282821 --- /dev/null +++ b/src/main/runtime/network-media-path.ts @@ -0,0 +1,142 @@ +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import process from 'node:process'; +import { resolveSubtitleSourcePath } from './subtitle-prefetch-source'; + +const DEFAULT_MOUNT_CACHE_TTL_MS = 5_000; +const NETWORK_FILESYSTEM_TYPES = new Set([ + '9p', + 'afpfs', + 'cifs', + 'davfs', + 'davfs2', + 'fuse.sshfs', + 'nfs', + 'nfs4', + 'smbfs', + 'sshfs', + 'webdav', +]); + +function isRemoteUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +function decodeMountPath(value: string): string { + return value.replace(/\\([0-7]{3})/g, (_match, digits: string) => + String.fromCharCode(Number.parseInt(digits, 8)), + ); +} + +function parseNetworkMountPaths(output: string): string[] { + const networkMountPaths: string[] = []; + for (const line of output.split('\n')) { + const optionsStart = line.lastIndexOf(' ('); + if (optionsStart < 0) continue; + + let mountDescription = line.slice(0, optionsStart); + const options = line.slice(optionsStart + 2, line.indexOf(')', optionsStart)); + const linuxTypeSeparator = mountDescription.lastIndexOf(' type '); + const filesystemType = ( + linuxTypeSeparator >= 0 + ? mountDescription.slice(linuxTypeSeparator + ' type '.length) + : (options.split(',').at(0) ?? '') + ) + .trim() + .toLowerCase(); + if (!NETWORK_FILESYSTEM_TYPES.has(filesystemType)) continue; + + if (linuxTypeSeparator >= 0) { + mountDescription = mountDescription.slice(0, linuxTypeSeparator); + } + const mountSeparator = mountDescription.indexOf(' on '); + if (mountSeparator < 0) continue; + networkMountPaths.push( + path.posix.normalize(decodeMountPath(mountDescription.slice(mountSeparator + 4).trim())), + ); + } + return networkMountPaths; +} + +function readMountOutput(platform: NodeJS.Platform): Promise { + if (platform === 'win32') return Promise.resolve(''); + const command = platform === 'darwin' ? '/sbin/mount' : 'mount'; + return new Promise((resolve, reject) => { + execFile( + command, + [], + { encoding: 'utf8', timeout: 1_000, maxBuffer: 1024 * 1024 }, + (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + }, + ); + }); +} + +function isPathWithinMount(filePath: string, mountPath: string): boolean { + const relativePath = path.posix.relative(mountPath, filePath); + return ( + relativePath === '' || + (relativePath !== '..' && + !relativePath.startsWith(`..${path.posix.sep}`) && + !path.posix.isAbsolute(relativePath)) + ); +} + +export type RemoteMediaPathDetector = (mediaPath: string) => Promise; + +export function createRemoteMediaPathDetector( + deps: { + platform?: NodeJS.Platform; + readMountOutput?: () => Promise; + now?: () => number; + mountCacheTtlMs?: number; + } = {}, +): RemoteMediaPathDetector { + const platform = deps.platform ?? process.platform; + const getMountOutput = deps.readMountOutput ?? (() => readMountOutput(platform)); + const now = deps.now ?? Date.now; + const mountCacheTtlMs = deps.mountCacheTtlMs ?? DEFAULT_MOUNT_CACHE_TTL_MS; + let mountCache: { expiresAt: number; networkMountPaths: Promise } | undefined; + + const getNetworkMountPaths = (): Promise => { + const currentTime = now(); + if (mountCache && currentTime < mountCache.expiresAt) { + return mountCache.networkMountPaths; + } + + const networkMountPaths = getMountOutput() + .then(parseNetworkMountPaths) + .catch(() => []); + mountCache = { + expiresAt: currentTime + mountCacheTtlMs, + networkMountPaths, + }; + return networkMountPaths; + }; + + return async (mediaPath): Promise => { + const source = mediaPath.trim(); + if (!source) return false; + if (isRemoteUrl(source)) return true; + + const filePath = resolveSubtitleSourcePath(source); + if (platform === 'win32') { + return filePath.startsWith('\\\\'); + } + if (!path.posix.isAbsolute(filePath)) return false; + + const networkMountPaths = await getNetworkMountPaths(); + const normalizedPath = path.posix.normalize(filePath); + return networkMountPaths.some((mountPath) => isPathWithinMount(normalizedPath, mountPath)); + }; +} diff --git a/src/main/runtime/overlay-runtime-bootstrap.ts b/src/main/runtime/overlay-runtime-bootstrap.ts index 287fba85..6266d394 100644 --- a/src/main/runtime/overlay-runtime-bootstrap.ts +++ b/src/main/runtime/overlay-runtime-bootstrap.ts @@ -26,6 +26,7 @@ type InitializeOverlayRuntimeCore = (options: { } | null; setAnkiIntegration: (integration: unknown | null) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; diff --git a/src/main/runtime/overlay-runtime-options-main-deps.test.ts b/src/main/runtime/overlay-runtime-options-main-deps.test.ts index 83f7f895..c6d48569 100644 --- a/src/main/runtime/overlay-runtime-options-main-deps.test.ts +++ b/src/main/runtime/overlay-runtime-options-main-deps.test.ts @@ -33,6 +33,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () => getOverlayWindows: () => [], getResolvedConfig: () => ({}), showDesktopNotification: () => calls.push('notify'), + showOverlayNotification: () => calls.push('show-overlay'), + dismissOverlayNotification: () => calls.push('dismiss-overlay'), createFieldGroupingCallback: () => async () => ({ keepNoteId: 1, deleteNoteId: 2, @@ -57,6 +59,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () => deps.refreshCurrentSubtitle?.(); deps.syncOverlayShortcuts(); deps.showDesktopNotification('title', {}); + deps.showOverlayNotification?.({ title: 'title' }); + deps.dismissOverlayNotification?.('notification-id'); const tracker = { close: () => {}, @@ -73,6 +77,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () => 'refresh-subtitle', 'sync-shortcuts', 'notify', + 'show-overlay', + 'dismiss-overlay', ]); assert.equal(appState.windowTracker, tracker); assert.deepEqual(appState.ankiIntegration, { id: 'anki' }); diff --git a/src/main/runtime/overlay-runtime-options-main-deps.ts b/src/main/runtime/overlay-runtime-options-main-deps.ts index a9150f91..d3000b2d 100644 --- a/src/main/runtime/overlay-runtime-options-main-deps.ts +++ b/src/main/runtime/overlay-runtime-options-main-deps.ts @@ -39,6 +39,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback']; getKnownWordCacheStatePath: () => string; getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath']; @@ -78,6 +79,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { }, showDesktopNotification: deps.showDesktopNotification, showOverlayNotification: deps.showOverlayNotification, + dismissOverlayNotification: deps.dismissOverlayNotification, createFieldGroupingCallback: () => deps.createFieldGroupingCallback(), getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(), ...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}), diff --git a/src/main/runtime/overlay-runtime-options.test.ts b/src/main/runtime/overlay-runtime-options.test.ts index 90a35960..2886aa6a 100644 --- a/src/main/runtime/overlay-runtime-options.test.ts +++ b/src/main/runtime/overlay-runtime-options.test.ts @@ -22,6 +22,8 @@ test('build initialize overlay runtime options maps dependencies', () => { getRuntimeOptionsManager: () => null, setAnkiIntegration: () => calls.push('set-anki'), showDesktopNotification: () => calls.push('notify'), + showOverlayNotification: () => calls.push('show-overlay'), + dismissOverlayNotification: () => calls.push('dismiss-overlay'), createFieldGroupingCallback: () => async () => ({ keepNoteId: 1, deleteNoteId: 2, @@ -47,6 +49,8 @@ test('build initialize overlay runtime options maps dependencies', () => { options.setWindowTracker(null); options.setAnkiIntegration(null); options.showDesktopNotification('title', {}); + options.showOverlayNotification?.({ title: 'title' }); + options.dismissOverlayNotification?.('notification-id'); assert.deepEqual(calls, [ 'create-main', @@ -58,5 +62,7 @@ test('build initialize overlay runtime options maps dependencies', () => { 'set-tracker', 'set-anki', 'notify', + 'show-overlay', + 'dismiss-overlay', ]); }); diff --git a/src/main/runtime/overlay-runtime-options.ts b/src/main/runtime/overlay-runtime-options.ts index 63d5f688..98e7e08a 100644 --- a/src/main/runtime/overlay-runtime-options.ts +++ b/src/main/runtime/overlay-runtime-options.ts @@ -33,6 +33,7 @@ type OverlayRuntimeOptions = { setAnkiIntegration: (integration: unknown | null) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -73,6 +74,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { setAnkiIntegration: (integration: unknown | null) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -107,6 +109,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { setAnkiIntegration: deps.setAnkiIntegration, showDesktopNotification: deps.showDesktopNotification, showOverlayNotification: deps.showOverlayNotification, + dismissOverlayNotification: deps.dismissOverlayNotification, createFieldGroupingCallback: deps.createFieldGroupingCallback, getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath, ...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}), diff --git a/src/main/runtime/primary-subtitle-text.test.ts b/src/main/runtime/primary-subtitle-text.test.ts new file mode 100644 index 00000000..3aa611e1 --- /dev/null +++ b/src/main/runtime/primary-subtitle-text.test.ts @@ -0,0 +1,676 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser'; +import { + resolveCanonicalPrimarySubtitle, + resolvePrimarySubtitleText, + stripCanonicalFragmentLines, +} from './primary-subtitle-text'; + +test('resolvePrimarySubtitleText collapses full-span ASS style layers through parsed cues', () => { + const ass = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 3,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)\\bord0\\blur0.8}鏡の奥まで目を凝らして', + 'Dialogue: 2,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)}鏡の奥まで目を凝らして', + 'Dialogue: 1,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)\\bord6}鏡の奥まで目を凝らして', + 'Dialogue: 0,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)\\bord8\\blur4}鏡の奥まで目を凝らして', + ].join('\n'); + const cues = parseSubtitleCues(ass, 'polar-opposites-s01e08.ass'); + + assert.deepEqual(cues, [ + { startTime: 22 * 60 + 40.05, endTime: 22 * 60 + 45.76, text: '鏡の奥まで目を凝らして' }, + ]); + + assert.equal( + resolvePrimarySubtitleText({ + liveText: [ + '鏡の奥まで目を凝らして', + '鏡の奥まで目を凝らして', + '鏡の奥まで目を凝らして', + '鏡の奥まで目を凝らして', + ].join('\n'), + currentTimeSec: 22 * 60 + 44, + cues, + }), + '鏡の奥まで目を凝らして', + ); +}); + +test('resolvePrimarySubtitleText keeps live text when active parsed cues do not explain it all', () => { + const liveText = '普通のセリフ\n鏡の奥まで目を凝らして\n鏡の奥まで目を凝らして'; + + assert.equal( + resolvePrimarySubtitleText({ + liveText, + currentTimeSec: 2, + cues: [{ startTime: 1, endTime: 3, text: '鏡の奥まで目を凝らして' }], + }), + liveText, + ); +}); + +test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () => { + assert.equal( + resolvePrimarySubtitleText({ + liveText: '一行目\n一行目\n二行目\n二行目', + currentTimeSec: 2, + cues: [ + { startTime: 1, endTime: 3, text: '一行目' }, + { startTime: 1, endTime: 3, text: '二行目' }, + ], + }), + '一行目\n\n二行目', + ); +}); + +test('resolvePrimarySubtitleText accounts for live ASS furigana after canonical recovery', () => { + const ass = [ + '[Script Info]', + 'PlayResY: 540', + '', + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(192,77)}ごめん 結局 ぬれたな。', + 'Comment: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,大丈夫。', + 'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,113)\\fscx50\\fscy50}だいじょうぶ', + 'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)\\clip(m 1 1)}大丈夫。', + 'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)\\clip(m 2 2)}大丈夫。', + 'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)\\clip(m 3 3)}大丈夫。', + ].join('\n'); + const cues = parseSubtitleCues(ass, 'polar-opposites-s02e08.ass'); + + assert.equal( + resolvePrimarySubtitleText({ + liveText: 'ごめん 結局 ぬれたな。\nだいじょうぶ\n大丈夫。', + currentTimeSec: 159, + cues, + }), + 'ごめん 結局 ぬれたな。\n\n大丈夫。', + ); +}); + +test('resolvePrimarySubtitleText removes duplicate lines across multiline parsed cues', () => { + assert.equal( + resolvePrimarySubtitleText({ + liveText: 'First line\nSecond line\nFirst line', + currentTimeSec: 2, + cues: [ + { startTime: 1, endTime: 3, text: 'First line\nSecond line' }, + { startTime: 1, endTime: 3, text: 'First line' }, + ], + }), + 'First line\nSecond line', + ); +}); + +test('resolvePrimarySubtitleText removes equivalent full-width duplicate lines', () => { + assert.equal( + resolvePrimarySubtitleText({ + liveText: '20分53秒\n20分53秒', + currentTimeSec: 2, + cues: [ + { startTime: 1, endTime: 3, text: '20分53秒' }, + { startTime: 1, endTime: 3, text: '20分53秒' }, + ], + }), + '20分53秒', + ); +}); + +test('resolvePrimarySubtitleText collapses whitespace variants of one ASS lyric', () => { + const ass = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 2,0:00:01.00,0:00:03.00,EDJP,,0,0,0,,少しだけ好きになる', + 'Dialogue: 1,0:00:01.00,0:00:03.00,EDJP,,0,0,0,,少しだけ\\h好きになる', + 'Dialogue: 0,0:00:01.00,0:00:03.00,EDJP,,0,0,0,,少しだけ 好きになる', + ].join('\n'); + const cues = parseSubtitleCues(ass, 'polar-opposites-s01e10.ass'); + + assert.deepEqual( + cues.map((cue) => cue.text), + ['少しだけ好きになる', '少しだけ 好きになる', '少しだけ 好きになる'], + ); + assert.equal( + resolvePrimarySubtitleText({ + liveText: ['少しだけ好きになる', '少しだけ 好きになる', '少しだけ 好きになる'].join('\n'), + currentTimeSec: 2, + cues, + }), + '少しだけ好きになる', + ); +}); + +test('resolvePrimarySubtitleText tolerates stale time-pos at a parsed cue edge', () => { + assert.equal( + resolvePrimarySubtitleText({ + liveText: '新しい行\n新しい行', + currentTimeSec: 0.8, + cues: [{ startTime: 1, endTime: 3, text: '新しい行' }], + }), + '新しい行', + ); +}); + +test('resolvePrimarySubtitleText prefers an active canonical cue over flattened mpv glyphs', () => { + // mpv renders each simultaneously active ASS event on its own sub-text line. + const text = resolvePrimarySubtitleText({ + liveText: '今\n今\n今\n手\n手\n手\nにある\nにある\nにある', + currentTimeSec: 2, + cues: [ + { + startTime: 1.2, + endTime: 3.8, + text: '今 手にある', + source: 'canonical-ass', + }, + ], + }); + + assert.equal(text, '今 手にある'); +}); + +test('resolvePrimarySubtitleText preserves live text outside canonical cue timing', () => { + const text = resolvePrimarySubtitleText({ + liveText: '通常の会話', + currentTimeSec: 8, + cues: [ + { + startTime: 1.2, + endTime: 3.8, + text: '今 手にある', + source: 'canonical-ass', + }, + ], + }); + + assert.equal(text, '通常の会話'); +}); + +test('resolvePrimarySubtitleText keeps concurrent dialogue that is not part of the animation', () => { + // An insert song's canonical window can overlap real dialogue on the same track. + const text = resolvePrimarySubtitleText({ + liveText: '普通のセリフ\n今\n手にある', + currentTimeSec: 2, + cues: [ + { + startTime: 1.2, + endTime: 3.8, + text: '今 手にある', + source: 'canonical-ass', + }, + ], + }); + + assert.equal(text, '普通のセリフ\n今\n手にある'); +}); + +test('resolvePrimarySubtitleText combines parsed dialogue with a reconstructed lyric', () => { + const text = resolvePrimarySubtitleText({ + liveText: '普通のセリフ\n今\n今\n手\n手\nにある\nにある', + currentTimeSec: 2, + cues: [ + { startTime: 1, endTime: 3, text: '普通のセリフ' }, + { + startTime: 1.2, + endTime: 3.8, + text: '今 手にある', + source: 'reconstructed-ass', + }, + ], + }); + + assert.equal(text, '普通のセリフ\n\n今 手にある'); +}); + +test('resolvePrimarySubtitleText uses fragment grids only to account for live sign pieces', () => { + const text = resolvePrimarySubtitleText({ + liveText: 'Ordinary dialogue\nMaid\nCafe', + currentTimeSec: 2, + cues: [ + { startTime: 1, endTime: 3, text: 'Ordinary dialogue' }, + { + startTime: 1, + endTime: 3, + text: 'MaidCafeMaidCafe', + source: 'reconstructed-ass', + assLayout: { kind: 'fragment-grid', sourceOrder: 2 }, + }, + ], + }); + + assert.equal(text, 'Ordinary dialogue'); +}); + +test('resolvePrimarySubtitleText drops malformed ASS control debris from live text', () => { + const cues = parseSubtitleCues( + [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Visible line', + ].join('\n'), + 'test.ass', + ); + + assert.equal( + resolvePrimarySubtitleText({ + liveText: 'Visible line\n\\\n{\\fr0', + currentTimeSec: 2, + cues, + }), + 'Visible line', + ); +}); + +test('resolvePrimarySubtitleText preserves SRT text that resembles ASS control debris', () => { + const liveText = 'Visible line\n\\\n{\\fr0'; + const cues = parseSubtitleCues( + ['1', '00:00:01,000 --> 00:00:03,000', liveText].join('\n'), + 'test.srt', + ); + + assert.equal(resolvePrimarySubtitleText({ liveText, currentTimeSec: 2, cues }), liveText); +}); + +test('resolvePrimarySubtitleText keeps a fresh line starting just after the animation ended', () => { + const text = resolvePrimarySubtitleText({ + liveText: '次のセリフ', + currentTimeSec: 4.1, + cues: [ + { + startTime: 1.2, + endTime: 3.8, + text: '今 手にある', + source: 'canonical-ass', + }, + ], + }); + + assert.equal(text, '次のセリフ'); +}); + +test('resolvePrimarySubtitleText survives overlapping frames of consecutive karaoke lines', () => { + // Near a line boundary the previous line's exit frames and the next line's entrance + // frames render together; neither line alone explains every live segment. + const cues = [ + { startTime: 1.2, endTime: 3.8, text: '今 手にある', source: 'canonical-ass' as const }, + { startTime: 3.8, endTime: 6.4, text: '物差しでは', source: 'canonical-ass' as const }, + ]; + + assert.equal( + resolvePrimarySubtitleText({ + liveText: '手にある\n物差し\nでは', + currentTimeSec: 3.6, + cues, + }), + '今 手にある', + ); + assert.equal( + resolvePrimarySubtitleText({ + liveText: '手にある\n物差し\nでは', + currentTimeSec: 3.9, + cues, + }), + '物差しでは', + ); +}); + +test('resolvePrimarySubtitleText combines simultaneous canonical cues in source order', () => { + const text = resolvePrimarySubtitleText({ + liveText: 'fir\nst\nsecond', + currentTimeSec: 2, + cues: [ + { startTime: 1, endTime: 3, text: 'first', source: 'canonical-ass' }, + { startTime: 1.5, endTime: 2.5, text: 'second', source: 'canonical-ass' }, + ], + }); + + assert.equal(text, 'first\n\nsecond'); +}); + +test('resolveCanonicalPrimarySubtitle orders active cues from top to bottom', () => { + const resolved = resolveCanonicalPrimarySubtitle({ + liveText: 'bottom\ntop', + currentTimeSec: 2, + cues: [ + { + startTime: 1, + endTime: 3, + text: 'bottom', + source: 'canonical-ass', + assLayout: { kind: 'source-order', sourceOrder: 1, verticalBand: 'bottom' }, + }, + { + startTime: 1, + endTime: 3, + text: 'top', + source: 'canonical-ass', + assLayout: { kind: 'source-order', sourceOrder: 0, verticalBand: 'top' }, + }, + ], + }); + + assert.equal(resolved?.text, 'top\n\nbottom'); +}); + +test('resolvePrimarySubtitleText collapses whitespace variants of a canonical lyric', () => { + assert.equal( + resolvePrimarySubtitleText({ + liveText: '少しだけ好きになる\n少しだけ 好きになる', + currentTimeSec: 2, + cues: [ + { startTime: 1, endTime: 3, text: '少しだけ好きになる', source: 'canonical-ass' }, + { startTime: 1, endTime: 3, text: '少しだけ 好きになる', source: 'canonical-ass' }, + ], + }), + '少しだけ好きになる', + ); +}); + +test('resolveCanonicalPrimarySubtitle covers a nearby generated animation edge', () => { + const cue = { + startTime: 1.2, + endTime: 3.8, + text: '今 手にある', + source: 'canonical-ass' as const, + }; + const resolved = resolveCanonicalPrimarySubtitle({ + liveText: '今\n手にある', + currentTimeSec: 0.8, + cues: [cue], + }); + + assert.deepEqual(resolved, { + text: '今 手にある', + startTime: 1.2, + endTime: 3.8, + cues: [cue], + }); +}); + +test('resolveCanonicalPrimarySubtitle covers exit frames that outlive the authored timing', () => { + // Real generated animations keep exit fragments on screen well past the authored + // comment window; the recorded animation envelope is what makes them resolvable. + const cue = { + startTime: 1.2, + endTime: 3.8, + text: '今 手にある', + source: 'canonical-ass' as const, + animationStartTime: 0.8, + animationEndTime: 5.6, + }; + const resolved = resolveCanonicalPrimarySubtitle({ + liveText: '今\n手にある', + currentTimeSec: 5.4, + cues: [cue], + }); + + assert.deepEqual(resolved, { + text: '今 手にある', + startTime: 1.2, + endTime: 3.8, + cues: [cue], + }); +}); + +test('resolvePrimarySubtitleText handles late exit frames overlapping the next active line', () => { + // The previous line's exit fragments can persist more than a second into the next + // authored line. The next line supplies the text; the previous line's envelope + // explains its lingering fragments. + const cues = [ + { + startTime: 1.2, + endTime: 3.8, + text: '今 手にある', + source: 'canonical-ass' as const, + animationStartTime: 0.8, + animationEndTime: 5.6, + }, + { + startTime: 3.8, + endTime: 6.4, + text: '物差しでは', + source: 'canonical-ass' as const, + animationStartTime: 3.4, + animationEndTime: 7.0, + }, + ]; + + assert.equal( + resolvePrimarySubtitleText({ + liveText: '手にある\n手にある\n物差し\nでは', + currentTimeSec: 5.2, + cues, + }), + '物差しでは', + ); +}); + +test('resolveCanonicalPrimarySubtitle rejects unrelated live text at the animation edge', () => { + const resolved = resolveCanonicalPrimarySubtitle({ + liveText: '次のセリフ', + currentTimeSec: 4.1, + cues: [ + { + startTime: 1.2, + endTime: 3.8, + text: '今 手にある', + source: 'canonical-ass', + }, + ], + }); + + assert.equal(resolved, null); +}); + +test('stripCanonicalFragmentLines drops fragment lines but keeps concurrent dialogue', () => { + const cues = [ + { + startTime: 1.2, + endTime: 3.8, + text: '今 手にある', + source: 'canonical-ass' as const, + }, + ]; + + assert.equal( + stripCanonicalFragmentLines({ + liveText: '普通のセリフ\n今\n手にある', + currentTimeSec: 2, + cues, + }), + '普通のセリフ', + ); + // No canonical cue nearby: nothing to strip. + assert.equal( + stripCanonicalFragmentLines({ liveText: '普通のセリフ\n今', currentTimeSec: 30, cues }), + '普通のセリフ\n今', + ); + // Everything matched (defensive): return the input rather than empty text. + assert.equal( + stripCanonicalFragmentLines({ liveText: '今\n手にある', currentTimeSec: 2, cues }), + '今\n手にある', + ); +}); + +test('resolveCanonicalPrimarySubtitle picks the cue its fragments spell, not the nearest', () => { + // In the gap between two authored spans, the next line sits closer in time while only + // the previous line's exit fragments are on screen: the fragments decide. + const cues = [ + { + startTime: 1, + endTime: 3, + text: '今 手にある', + source: 'canonical-ass' as const, + animationStartTime: 0.6, + animationEndTime: 3.9, + }, + { + startTime: 4, + endTime: 6, + text: '物差しでは', + source: 'canonical-ass' as const, + animationStartTime: 3.5, + animationEndTime: 6.4, + }, + ]; + + assert.equal( + resolveCanonicalPrimarySubtitle({ liveText: '手にある', currentTimeSec: 3.8, cues })?.text, + '今 手にある', + ); + // Fragments of both lines in the gap: both envelopes cover the moment (distance 0), + // and the earlier line wins the tie while it is still animating out. + assert.equal( + resolveCanonicalPrimarySubtitle({ liveText: '手にある\n物差し', currentTimeSec: 3.8, cues }) + ?.text, + '今 手にある', + ); +}); + +test('resolvePrimarySubtitleText suppresses a live glyph wall when no cues are available', () => { + const wall = [...'wansdumretoikhI'].join('\n'); + assert.equal( + resolvePrimarySubtitleText({ liveText: `${wall}\ntai`, currentTimeSec: 1355, cues: null }), + '', + ); +}); + +test('stripCanonicalFragmentLines drops a live glyph wall with no nearby canonical cues', () => { + const wall = [...'wansdumretoikhI'].join('\n'); + assert.equal( + stripCanonicalFragmentLines({ + liveText: `${wall}\nそれよりも ノート…`, + currentTimeSec: 1355, + cues: [], + }), + 'それよりも ノート…', + ); +}); + +test('resolvePrimarySubtitleText keeps a line joining an active cue despite stale time-pos', () => { + // Issue #220: mpv publishes the combined sub-text the moment a joining line's first + // frame renders, while the observed time-pos still sits just before that line's + // start. The joining cue must not be filtered out as inactive. + assert.equal( + resolvePrimarySubtitleText({ + liveText: 'Балда! Балда, балда, балда!\nСестренка не может остановиться', + currentTimeSec: 767.78, + cues: [ + { startTime: 767.19, endTime: 772.78, text: 'Балда! Балда, балда, балда!' }, + { startTime: 767.79, endTime: 771.15, text: 'Сестренка не может остановиться' }, + ], + }), + 'Балда! Балда, балда, балда!\n\nСестренка не может остановиться', + ); +}); + +test('resolvePrimarySubtitleText drops a finished lyric whose exit ghosts outlive it beside a raw line', () => { + // The reconstructed lyric ended at 6.0 but its exit ghost glyphs stay in the live + // text until 7.0, while the next authored line is a plain raw event. The retired cue + // must explain the ghost fragments without re-surfacing next to the active line. + const cues = [ + { + startTime: 1.0, + endTime: 6.0, + text: 'エネルギーはサイクル', + source: 'reconstructed-ass' as const, + animationStartTime: 0.5, + animationEndTime: 7.0, + assStyle: 'OP - JP', + }, + { startTime: 6.0, endTime: 12.0, text: '象徴的なパレード' }, + ]; + + assert.equal( + resolvePrimarySubtitleText({ + liveText: 'エ\nネ\nル\nギ\nー\n象徴的なパレード', + currentTimeSec: 6.5, + cues, + }), + '象徴的なパレード', + ); +}); + +test('resolvePrimarySubtitleText stacks simultaneous cues by screen position, not start order', () => { + // A top-anchored lyric and bottom dialogue: mpv draws the lyric above the dialogue for + // the whole overlap. Whichever event started first must not decide the row, or the + // pair swaps every time one side is replaced mid-overlap. + const lyricLayout = { kind: 'source-order', sourceOrder: 0, verticalBand: 'top' } as const; + const dialogueLayout = { kind: 'source-order', sourceOrder: 1, verticalBand: 'bottom' } as const; + const dialogue = { + startTime: 632.2, + endTime: 634.8, + text: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b', + assLayout: dialogueLayout, + }; + + // Lyric started before the dialogue... + assert.equal( + resolvePrimarySubtitleText({ + liveText: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b\n\u6b4c\u8a5e\uff21', + currentTimeSec: 632.5, + cues: [ + { startTime: 629.5, endTime: 633.5, text: '\u6b4c\u8a5e\uff21', assLayout: lyricLayout }, + dialogue, + ], + }), + '\u6b4c\u8a5e\uff21\n\n\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b', + ); + // ...and the next lyric starts after it: the rows must not swap. + assert.equal( + resolvePrimarySubtitleText({ + liveText: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b\n\u6b4c\u8a5e\uff22', + currentTimeSec: 633.8, + cues: [ + dialogue, + { startTime: 633.5, endTime: 637.0, text: '\u6b4c\u8a5e\uff22', assLayout: lyricLayout }, + ], + }), + '\u6b4c\u8a5e\uff22\n\n\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b', + ); +}); + +test('resolvePrimarySubtitleText puts an unreadable placement above bottom dialogue', () => { + // Dialogue is the case that reliably declares a bottom alignment, so a cue whose + // placement could not be read is more often a sign or song line. Keeping dialogue on + // the bottom row means the line worth reading stays where the eye already is. + assert.equal( + resolvePrimarySubtitleText({ + liveText: '\u4e0b\u306e\u30bb\u30ea\u30d5\n\u4e0d\u660e\u306a\u884c', + currentTimeSec: 2, + cues: [ + { + startTime: 1, + endTime: 3, + text: '\u4e0b\u306e\u30bb\u30ea\u30d5', + assLayout: { kind: 'source-order', sourceOrder: 0, verticalBand: 'bottom' }, + }, + { + startTime: 1.5, + endTime: 3, + text: '\u4e0d\u660e\u306a\u884c', + assLayout: { kind: 'source-order', sourceOrder: 1 }, + }, + ], + }), + '\u4e0d\u660e\u306a\u884c\n\n\u4e0b\u306e\u30bb\u30ea\u30d5', + ); +}); + +test('resolvePrimarySubtitleText keeps source order when no cue declares a placement', () => { + // SRT and websocket cues carry no layout at all: every cue ties, so the stable sort + // must leave them exactly as the cue list had them. + assert.equal( + resolvePrimarySubtitleText({ + liveText: 'First line\nSecond line', + currentTimeSec: 2, + cues: [ + { startTime: 1, endTime: 3, text: 'First line' }, + { startTime: 1.5, endTime: 3, text: 'Second line' }, + ], + }), + 'First line\n\nSecond line', + ); +}); diff --git a/src/main/runtime/primary-subtitle-text.ts b/src/main/runtime/primary-subtitle-text.ts new file mode 100644 index 00000000..fc588b8f --- /dev/null +++ b/src/main/runtime/primary-subtitle-text.ts @@ -0,0 +1,313 @@ +import type { AssVerticalBand, SubtitleCue } from '../../types'; +import { + removeAssControlDebrisLines, + removeLiveGlyphFragmentLines, +} from '../../core/services/ass-text'; + +// Slack on top of each cue's recorded animation envelope, for time-pos observation +// staleness and small user sub-delay offsets. The envelope itself covers how far +// entrance/exit frames actually run past the authored timing. +const LIVE_CUE_EDGE_TOLERANCE_SECONDS = 1; + +export interface ResolvedPrimarySubtitle { + text: string; + startTime: number; + endTime: number; + /** The parsed cues behind `text`, for consumers that record lines individually. */ + cues: SubtitleCue[]; +} + +function cuesUseAssSyntax(cues: readonly SubtitleCue[] | null | undefined): boolean { + return (cues ?? []).some( + (cue) => + cue.source === 'canonical-ass' || + cue.source === 'reconstructed-ass' || + cue.assLayout !== undefined, + ); +} + +function animationSpan(cue: SubtitleCue): { start: number; end: number } { + return { + start: cue.animationStartTime ?? cue.startTime, + end: cue.animationEndTime ?? cue.endTime, + }; +} + +function nearbyCanonicalCues( + cues: readonly SubtitleCue[] | null | undefined, + currentTimeSec: number, + includeFragmentGrids = false, +): SubtitleCue[] { + return (cues ?? []).filter((cue) => { + if ( + (cue.source !== 'canonical-ass' && cue.source !== 'reconstructed-ass') || + (!includeFragmentGrids && cue.assLayout?.kind === 'fragment-grid') + ) { + return false; + } + const span = animationSpan(cue); + return ( + span.end >= currentTimeSec - LIVE_CUE_EDGE_TOLERANCE_SECONDS && + span.start <= currentTimeSec + LIVE_CUE_EDGE_TOLERANCE_SECONDS + ); + }); +} + +function compactWhitespace(text: string): string { + return text.normalize('NFKC').replace(/\s+/gu, ''); +} + +/** + * Distinct simultaneous cues are separated by a blank line so the display layer can tell + * a wrap inside one utterance from the boundary between two of them. Consumers that read + * the text rather than display it fold these back to single breaks. + */ +const CUE_BOUNDARY = '\n\n'; + +const VERTICAL_BAND_RANK: Record = { top: 0, middle: 1, bottom: 2 }; + +/** + * Stack simultaneous cues the way they sit on screen: mpv keeps a top-anchored lyric or + * sign above bottom dialogue for its whole run, while cue-list order follows start time + * and would swap the pair whenever one side is replaced mid-overlap. The band is + * constant per event, so a line never changes rows while it is displayed. + * + * A cue whose placement could not be read -- an unknown style, a script with no styles + * section -- sorts to the top. Dialogue is the case that reliably declares a bottom + * alignment, so what is left unresolved is more often a sign or a song line, and keeping + * the dialogue on the bottom row means the line worth reading stays where the eye + * already is. Sort is stable, so cues sharing a rank keep their existing order. + */ +function orderCuesForDisplay(cues: readonly SubtitleCue[]): SubtitleCue[] { + const rank = (cue: SubtitleCue): number => + VERTICAL_BAND_RANK[cue.assLayout?.verticalBand ?? 'top']; + return [...cues].sort((a, b) => rank(a) - rank(b)); +} + +// ASS layers can encode the same visible spacing with ordinary, hard, or +// ideographic spaces. Matching and emission must use the same identity or each +// layer reappears as a copy. +function uniqueCueTextGroups(cues: readonly SubtitleCue[]): string[] { + const groups: string[] = []; + const seen = new Set(); + for (const cue of cues) { + const lines: string[] = []; + for (const line of cue.text.split('\n')) { + const compactText = compactWhitespace(line); + if (!compactText || seen.has(compactText)) continue; + seen.add(compactText); + lines.push(line); + } + if (lines.length > 0) { + groups.push(lines.join('\n')); + } + } + return groups; +} + +function compactLineSegments(text: string): string[] { + return text.split('\n').map(compactWhitespace).filter(Boolean); +} + +/** + * Parsed cues have already collapsed exact ASS layers and animation runs. Trust that + * cleaner view only when every live mpv line is accounted for by an active parsed cue. + * This keeps unrelated concurrent dialogue on the live fallback while removing style + * stacks where mpv repeats one full lyric for fill, border, blur, and shadow layers. + */ +function resolveActiveParsedPrimarySubtitle(options: { + liveText: string; + currentTimeSec: number; + cues: readonly SubtitleCue[] | null | undefined; +}): ResolvedPrimarySubtitle | null { + if (!Number.isFinite(options.currentTimeSec)) { + return null; + } + + const liveSegments = compactLineSegments(options.liveText); + if (liveSegments.length === 0) { + return null; + } + const liveSegmentSet = new Set(liveSegments); + const selected = (options.cues ?? []).filter((cue) => { + if ( + cue.startTime > options.currentTimeSec + LIVE_CUE_EDGE_TOLERANCE_SECONDS || + cue.endTime <= options.currentTimeSec - LIVE_CUE_EDGE_TOLERANCE_SECONDS + ) { + return false; + } + const cueSegments = compactLineSegments(cue.text); + if (cueSegments.length === 0) return false; + if (cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass') { + return liveSegments.some((segment) => + cueSegments.some((cueSegment) => cueSegment.includes(segment)), + ); + } + return cueSegments.every((segment) => liveSegmentSet.has(segment)); + }); + if (selected.length === 0) { + return null; + } + + const parsedSegments = selected.flatMap((cue) => { + const recovered = cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass'; + return [ + ...compactLineSegments(cue.text).map((segment) => ({ segment, recovered })), + ...(cue.assFurigana ?? []).flatMap((text) => + compactLineSegments(text).map((segment) => ({ segment, recovered: false })), + ), + ]; + }); + if ( + !liveSegments.every((liveSegment) => + parsedSegments.some(({ segment, recovered }) => + recovered ? segment.includes(liveSegment) : segment === liveSegment, + ), + ) + ) { + return null; + } + + // A cue selected only through the edge tolerance on its end has already finished by + // its published timing: a lyric whose exit ghosts linger into the next line. It still + // explains those live fragments above, but must not re-surface beside cues that are + // still running. The start side keeps the tolerance: mpv publishes the combined + // sub-text the moment a joining line's first frame renders, while the observed + // time-pos still sits just before that line's start, and the selection above already + // required the cue's text to be on screen (#220). With every selected cue finished, + // the edge cues remain the display fallback for stale time-pos readings. + const unfinished = selected.filter((cue) => cue.endTime > options.currentTimeSec); + const displayCues = unfinished.length > 0 ? unfinished : selected; + + // Dense sign grids still explain their raw mpv fragments, but are visual + // typesetting rather than a publishable subtitle line. + const groups = uniqueCueTextGroups( + orderCuesForDisplay(displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid')), + ); + return { + text: groups.join(CUE_BOUNDARY), + startTime: Math.min(...displayCues.map((cue) => cue.startTime)), + endTime: Math.max(...displayCues.map((cue) => cue.endTime)), + cues: displayCues, + }; +} + +/** + * mpv's `sub-text` renders each simultaneously active ASS event on its own line, so + * while a generated animation plays every live line is a contiguous piece of the + * authored text. A line that is not -- concurrent dialogue during an insert song, or a + * fresh line starting just after the animation ended -- proves the live text is not this + * animation, and substituting the canonical line would swallow real dialogue. + */ +function liveTextIsFromCues(liveText: string, cues: readonly SubtitleCue[]): boolean { + const compactCues = cues.map((cue) => compactWhitespace(cue.text)); + const segments = liveText.split('\n').map(compactWhitespace).filter(Boolean); + return ( + segments.length > 0 && + segments.every((segment) => compactCues.some((cueText) => cueText.includes(segment))) + ); +} + +export function resolveCanonicalPrimarySubtitle(options: { + liveText: string; + currentTimeSec: number; + cues: readonly SubtitleCue[] | null | undefined; +}): ResolvedPrimarySubtitle | null { + if (!Number.isFinite(options.currentTimeSec)) { + return null; + } + + // Consecutive karaoke lines overlap: one line's exit frames are still on screen while + // the next line's entrance frames appear. The fragment check therefore runs against + // every canonical cue whose animation envelope reaches the current time, while only + // the active (or single nearest) cue supplies the displayed text. + const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec); + const active = nearby.filter( + (cue) => cue.startTime <= options.currentTimeSec && cue.endTime > options.currentTimeSec, + ); + const liveSegments = options.liveText.split('\n').map(compactWhitespace).filter(Boolean); + const selected = + active.length > 0 + ? active + : nearby + // Between authored spans, proximity alone can pick the wrong neighbor: the + // next line can sit closer while only the previous line's exit fragments are + // on screen. Only cues that explain at least one live line may be selected. + .filter((cue) => { + const cueText = compactWhitespace(cue.text); + return liveSegments.some((segment) => cueText.includes(segment)); + }) + .map((cue) => { + const span = animationSpan(cue); + const distance = + options.currentTimeSec < span.start + ? span.start - options.currentTimeSec + : Math.max(0, options.currentTimeSec - span.end); + return { cue, distance }; + }) + .sort((a, b) => a.distance - b.distance || a.cue.startTime - b.cue.startTime) + .slice(0, 1) + .map(({ cue }) => cue); + if (selected.length === 0 || !liveTextIsFromCues(options.liveText, nearby)) { + return null; + } + + const groups = uniqueCueTextGroups(orderCuesForDisplay(selected)); + return { + text: groups.join(CUE_BOUNDARY), + startTime: Math.min(...selected.map((cue) => cue.startTime)), + endTime: Math.max(...selected.map((cue) => cue.endTime)), + cues: selected, + }; +} + +/** + * Live text with generated-animation fragment lines removed. Recording paths use this + * when full canonical substitution declined -- concurrent dialogue during an insert + * song: the dialogue is worth recording, the glyph fragments beside it are not. An + * all-fragment visual grid becomes empty; other all-matched input remains unchanged as a + * defensive fallback. + */ +export function stripCanonicalFragmentLines(options: { + liveText: string; + currentTimeSec: number; + cues: readonly SubtitleCue[] | null | undefined; +}): string { + if (!Number.isFinite(options.currentTimeSec)) { + return removeLiveGlyphFragmentLines(options.liveText); + } + const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec, true); + if (nearby.length === 0) { + return removeLiveGlyphFragmentLines(options.liveText); + } + const compactCues = nearby.map((cue) => compactWhitespace(cue.text)); + const kept = options.liveText.split('\n').filter((line) => { + const compact = compactWhitespace(line); + return compact && !compactCues.some((cueText) => cueText.includes(compact)); + }); + if (kept.length > 0) return removeLiveGlyphFragmentLines(kept.join('\n')); + if (nearby.some((cue) => cue.assLayout?.kind === 'fragment-grid')) return ''; + return removeLiveGlyphFragmentLines(options.liveText); +} + +export function resolvePrimarySubtitleText(options: { + liveText: string; + currentTimeSec: number; + cues: readonly SubtitleCue[] | null | undefined; +}): string { + const liveText = cuesUseAssSyntax(options.cues) + ? removeAssControlDebrisLines(options.liveText) + : options.liveText; + if (!liveText.trim()) { + return liveText; + } + return ( + resolveCanonicalPrimarySubtitle({ + liveText, + currentTimeSec: options.currentTimeSec, + cues: options.cues, + })?.text ?? + resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ?? + removeLiveGlyphFragmentLines(liveText) + ); +} diff --git a/src/main/runtime/secondary-subtitle-track.test.ts b/src/main/runtime/secondary-subtitle-track.test.ts new file mode 100644 index 00000000..cc553216 --- /dev/null +++ b/src/main/runtime/secondary-subtitle-track.test.ts @@ -0,0 +1,691 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser'; +import { + createSecondarySubtitleTrackController, + findActiveSubtitleText, +} from './secondary-subtitle-track'; + +test('findActiveSubtitleText combines unique simultaneous parsed cues', () => { + assert.equal( + findActiveSubtitleText( + [ + { startTime: 1, endTime: 3, text: 'Your' }, + { startTime: 1, endTime: 3, text: 'Your' }, + { startTime: 1, endTime: 3, text: 'mosaic' }, + ], + 2, + ), + 'Your\nmosaic', + ); +}); + +test('findActiveSubtitleText removes duplicate lines across multiline cues', () => { + assert.equal( + findActiveSubtitleText( + [ + { startTime: 1, endTime: 3, text: 'First line\nSecond line' }, + { startTime: 1, endTime: 3, text: 'First line' }, + ], + 2, + ), + 'First line\nSecond line', + ); +}); + +test('findActiveSubtitleText removes equivalent full-width duplicate lines', () => { + assert.equal( + findActiveSubtitleText( + [ + { startTime: 1, endTime: 3, text: '真白~' }, + { startTime: 1, endTime: 3, text: '真白~' }, + ], + 2, + ), + '真白~', + ); +}); + +test('findActiveSubtitleText collapses whitespace variants of one ASS lyric', () => { + assert.equal( + findActiveSubtitleText( + [ + { startTime: 1, endTime: 3, text: '少しだけ好きになる' }, + { startTime: 1, endTime: 3, text: '少しだけ 好きになる' }, + { startTime: 1, endTime: 3, text: '少しだけ 好きになる' }, + ], + 2, + ), + '少しだけ好きになる', + ); +}); + +test('parsed secondary text collapses a positioned sign that repeats dialogue without punctuation', () => { + const ass = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 10,0:03:58.49,0:04:00.34,GJM_Main_1080p,Nar,0,0,0,,{\\i1}A question veiled as an insult!', + 'Dialogue: 1,0:03:58.59,0:04:00.34,iFanzSigns,,0,0,0,,{\\pos(960,75)}A question veiled as an insult', + ].join('\n'); + const cues = parseSubtitleCues(ass, 'kaguya-s02e10.ass'); + + assert.equal(findActiveSubtitleText(cues, 238.48), ''); + assert.equal(findActiveSubtitleText(cues, 238.5), 'A question veiled as an insult!'); + assert.equal(findActiveSubtitleText(cues, 239), 'A question veiled as an insult!'); + assert.equal(findActiveSubtitleText(cues, 240.34), ''); +}); + +test('parsed secondary text drops a reconstructed grid of positioned sign fragments', () => { + const signFragment = (text: string, x: number, y: number) => + `Dialogue: 1,0:00:01.00,0:00:03.00,Signs,,0,0,0,,{\\pos(${x},${y})\\t(0,100,\\fscx101)}${text}`; + const ass = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 10,0:00:01.00,0:00:03.00,Default,Speaker,0,0,0,,Come on, wake up!', + signFragment('Timetable', 1700, 150), + signFragment('Mon', 1750, 230), + signFragment('Tue', 1850, 230), + signFragment('1', 1650, 320), + signFragment('2', 1650, 390), + signFragment('Civics', 1750, 320), + signFragment('Math', 1850, 390), + signFragment('PE', 1850, 460), + ].join('\n'); + + assert.equal( + findActiveSubtitleText(parseSubtitleCues(ass, 'kaguya-s02e11.ass'), 2), + 'Come on, wake up!', + ); +}); + +test('parsed secondary text keeps phone translations while dropping texture payloads', () => { + const ass = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 2,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain Medium\\clip(500,40,660,150)}LLLLLLLLLLLL', + 'Dialogue: 90,0:00:01.00,0:00:03.00,Default,,0,0,0,,Why did you choose Hanajo instead?', + "Dialogue: 1,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain\\fs10\\alpha&H70&}q26D'vrA;\\NE? GS\\NESLhlawEv", + "Dialogue: 3,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(582,180)\\fnSF Pro Display\\fs66}We're {\\2a0}running {\\2a1}out {\\2a0}of {\\2a1}time!\\N{\\2a0}Where {\\2a1}are {\\2a0}you {\\2a1}right {\\2a0}now?!", + ].join('\n'); + + assert.equal( + findActiveSubtitleText(parseSubtitleCues(ass, 'phone.ass'), 2), + "Why did you choose Hanajo instead?\nWe're running out of time!\nWhere are you right now?!", + ); +}); + +test('parsed secondary lyrics keep explicit ASS vertical order when durations alternate', () => { + const lyric = (options: { start: string; end: string; style: string; y: number; text: string }) => + `Dialogue: 0,0:00:${options.start},0:00:${options.end},${options.style},,0,0,0,fx,{\\move(100,${options.y},120,${options.y})\\t(0,200,\\fscx110)}${options.text}\\N{\\p1}m 0 0 l 0 5`; + const ass = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + lyric({ + start: '01.00', + end: '02.20', + style: 'ed_romaji', + y: 66, + text: 'ima wo kakusarechau mae ni', + }), + lyric({ + start: '01.00', + end: '02.00', + style: 'ed_english', + y: 1020, + text: 'Before the present moment gets hidden away.', + }), + lyric({ + start: '03.00', + end: '04.00', + style: 'ed_romaji', + y: 66, + text: 'ame mitai ni hikatteru', + }), + lyric({ + start: '03.00', + end: '04.20', + style: 'ed_english', + y: 1020, + text: 'Is shining like rain.', + }), + ].join('\n'); + const cues = parseSubtitleCues(ass, 'polar-opposites-s01e08.ass'); + + assert.equal( + findActiveSubtitleText(cues, 1.5), + 'ima wo kakusarechau mae ni\nBefore the present moment gets hidden away.', + ); + assert.equal(findActiveSubtitleText(cues, 3.5), 'ame mitai ni hikatteru\nIs shining like rain.'); +}); + +test('unpositioned secondary lyrics fall back to ASS source order', () => { + const ass = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:02.20,ED Romaji,,0,0,0,,ima wo kakusarechau mae ni', + 'Dialogue: 0,0:00:01.00,0:00:02.00,ED English,,0,0,0,,Before the present moment gets hidden away.', + ].join('\n'); + + assert.equal( + findActiveSubtitleText(parseSubtitleCues(ass, 'ending.ass'), 1.5), + 'ima wo kakusarechau mae ni\nBefore the present moment gets hidden away.', + ); +}); + +test('findActiveSubtitleText keeps a canonical ASS cue for its generated animation span', () => { + const poof = { + startTime: 1110.67, + endTime: 1110.71, + text: 'POOF', + source: 'canonical-ass' as const, + animationStartTime: 1110.67, + animationEndTime: 1111.59, + }; + + assert.equal(findActiveSubtitleText([poof], 1111.58), 'POOF'); + assert.equal(findActiveSubtitleText([poof], 1111.59), ''); +}); + +test('findActiveSubtitleText advances when the next canonical lyric animation starts', () => { + const cues = [ + { + startTime: 121.73, + endTime: 124.1, + text: 'Torn at the seams, a sound pours out', + source: 'canonical-ass' as const, + animationStartTime: 121.4, + animationEndTime: 124.1, + }, + { + startTime: 124.13, + endTime: 126.38, + text: 'It’s silent, yet spreads all around', + source: 'canonical-ass' as const, + animationStartTime: 123.8, + animationEndTime: 126.38, + }, + ]; + + assert.equal(findActiveSubtitleText(cues, 123.79), cues[0]!.text); + assert.equal(findActiveSubtitleText(cues, 123.8), cues[1]!.text); +}); + +test('ASS fragment karaoke stays separated by style with authored word spacing', () => { + const lineEvents = ( + style: string, + fragments: readonly string[], + y: number, + baseTime = 1, + ): string[] => { + const events: string[] = []; + for (const layer of [0, 1]) { + fragments.forEach((fragment, index) => { + const x = 100 + index * 40; + const start = (baseTime + index * 0.25).toFixed(2).padStart(5, '0'); + const end = (baseTime + 3 + index * 0.2).toFixed(2).padStart(5, '0'); + events.push( + `Dialogue: ${layer},0:00:${start},0:00:${end},${style},,0,0,0,,{\\pos(${x},${y})\\t(0,200,\\fscx110)}${fragment}\\N{\\p1}m 0 0 l 0 10`, + ); + }); + } + return events; + }; + const ass = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...lineEvents('ed_romaji', ['ji', 'gu', 'za', 'gu ', 'na', 'mi'], 70), + ...lineEvents('ed_english', ['Pas', 'si', 'ng ', 'thro', 'u', 'gh '], 110), + ...lineEvents('op_english', ['I', 'want', 'to', 'go'], 110, 7), + ].join('\n'); + + assert.equal( + findActiveSubtitleText(parseSubtitleCues(ass, 'ending.ass'), 2.5), + 'jiguzagu nami\nPassing through', + ); + // Some generated scripts discard spaces and retain only positioned chunks. Joining + // without invented separators avoids turning one word into spaced syllables. + assert.equal(findActiveSubtitleText(parseSubtitleCues(ass, 'ending.ass'), 8.5), 'Iwanttogo'); +}); + +test('ASS fragment karaoke preserves word spaces authored at event boundaries', () => { + const fragments = [ + 'The ', + 'shoot', + 'ing ', + 'stars ', + 'arc', + 'ing ', + 'across ', + 'the ', + 'sky ', + 'I ', + 'wish ', + 'upon,', + ]; + const events: string[] = []; + for (const layer of [0, 1]) { + fragments.forEach((fragment, index) => { + events.push( + `Dialogue: ${layer},0:00:01.00,0:00:04.00,op_english,,0,0,0,,{\\pos(${100 + index * 40},110)\\t(0,200,\\fscx110)}${fragment}`, + ); + }); + } + const ass = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...events, + ].join('\n'); + + assert.equal( + findActiveSubtitleText(parseSubtitleCues(ass, 'bravern-s01e10.ass'), 2), + 'The shooting stars arcing across the sky I wish upon,', + ); +}); + +test('findActiveSubtitleText keeps a complete reconstructed line over entrance fragments', () => { + const current = { + startTime: 1, + endTime: 4, + text: 'Complete current line', + source: 'reconstructed-ass' as const, + assStyle: 'op_english', + }; + const nextEntrance = { + startTime: 3.8, + endTime: 4.2, + text: 'Ne', + source: 'reconstructed-ass' as const, + assStyle: 'op_english', + }; + const nextLine = { + startTime: 4, + endTime: 7, + text: 'Next complete line', + source: 'reconstructed-ass' as const, + assStyle: 'op_english', + }; + + assert.equal(findActiveSubtitleText([current, nextEntrance], 3.9), current.text); + assert.equal(findActiveSubtitleText([current, nextEntrance, nextLine], 4.1), nextLine.text); +}); + +test('secondary track controller parses the selected ASS file before publishing', async () => { + const broadcasts: string[] = []; + let currentText = ''; + const resolverInputs: Array<{ allowSelectedFallback?: boolean }> = []; + const ass = `[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your +Dialogue: 1,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your +Dialogue: 2,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your +Dialogue: 3,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your +Dialogue: 4,0:00:01.00,0:00:03.00,Sign,,0,0,0,,mosaic`; + const controller = createSecondarySubtitleTrackController({ + getMpvClient: () => ({ + connected: true, + requestProperty: async (name) => { + if (name === 'secondary-sid') return 2; + if (name === 'track-list') return [{ type: 'sub', id: 2 }]; + if (name === 'path') return '/media/video.mkv'; + if (name === 'secondary-sub-delay') return 0; + return null; + }, + }), + getCurrentTimePos: () => 2, + resolveSubtitleSource: async (input) => { + resolverInputs.push(input); + return { path: '/subs/english.ass', sourceKey: '/subs/english.ass' }; + }, + loadSubtitleSourceText: async () => ass, + parseSubtitleCues, + setCurrentSecondaryText: (text) => { + currentText = text; + }, + broadcastSecondaryText: (text) => broadcasts.push(text), + }); + + await controller.refresh(); + controller.handleLiveText('Your\nYour\nYour\nYour\nmosaic'); + + assert.equal(resolverInputs[0]?.allowSelectedFallback, false); + assert.equal(currentText, 'Your\nmosaic'); + assert.deepEqual(broadcasts, ['Your\nmosaic']); +}); + +test('secondary track controller follows parsed cue timing and subtitle delay', async () => { + const broadcasts: string[] = []; + let time = 2.25; + const controller = createSecondarySubtitleTrackController({ + getMpvClient: () => ({ + connected: true, + requestProperty: async (name) => { + if (name === 'secondary-sid') return 2; + if (name === 'track-list') return [{ type: 'sub', id: 2 }]; + if (name === 'path') return '/media/video.mkv'; + if (name === 'secondary-sub-delay') return 0.5; + return null; + }, + }), + getCurrentTimePos: () => time, + resolveSubtitleSource: async () => ({ path: '/subs/english.srt', sourceKey: 'english' }), + loadSubtitleSourceText: async () => '', + parseSubtitleCues: () => [ + { startTime: 1, endTime: 2, text: 'first' }, + { startTime: 2, endTime: 3, text: 'second' }, + ], + setCurrentSecondaryText: () => {}, + broadcastSecondaryText: (text) => broadcasts.push(text), + }); + + await controller.refresh(); + controller.handleDelayChange(0); + time = 3.25; + controller.handleTimePos(time); + + assert.deepEqual(broadcasts, ['first', 'second', '']); +}); + +test('secondary track controller clears old parsed text immediately on a track change', async () => { + const broadcasts: string[] = []; + let currentText = ''; + const controller = createSecondarySubtitleTrackController({ + getMpvClient: () => ({ + connected: true, + requestProperty: async (name) => { + if (name === 'secondary-sid') return 2; + if (name === 'track-list') return [{ type: 'sub', id: 2, external: true }]; + if (name === 'path') return '/media/video.mkv'; + if (name === 'secondary-sub-delay') return 0; + return null; + }, + }), + getCurrentTimePos: () => 2, + resolveSubtitleSource: async () => ({ path: '/subs/old.ass', sourceKey: 'old' }), + loadSubtitleSourceText: async () => '', + parseSubtitleCues: () => [{ startTime: 1, endTime: 3, text: 'old parsed text' }], + setCurrentSecondaryText: (text) => { + currentText = text; + }, + broadcastSecondaryText: (text) => broadcasts.push(text), + }); + + await controller.refresh(); + controller.handleTrackChange(); + controller.handleLiveText('new live text'); + + assert.equal(currentText, 'new live text'); + assert.deepEqual(broadcasts, ['old parsed text', '', 'new live text']); +}); + +test('secondary track controller falls back to live mpv text without a readable source', async () => { + const broadcasts: string[] = []; + let currentText = ''; + const controller = createSecondarySubtitleTrackController({ + getMpvClient: () => ({ + connected: true, + requestProperty: async (name) => { + if (name === 'secondary-sid') return 'no'; + if (name === 'path') return '/media/video.mkv'; + return null; + }, + }), + getCurrentTimePos: () => 2, + resolveSubtitleSource: async () => null, + loadSubtitleSourceText: async () => '', + parseSubtitleCues: () => [], + setCurrentSecondaryText: (text) => { + currentText = text; + }, + broadcastSecondaryText: (text) => broadcasts.push(text), + }); + + controller.handleLiveText('live fallback'); + await controller.refresh(); + + assert.equal(currentText, 'live fallback'); + assert.deepEqual(broadcasts, ['live fallback']); +}); + +test('secondary ASS live fallback drops malformed control debris', async () => { + const broadcasts: string[] = []; + const controller = createSecondarySubtitleTrackController({ + getMpvClient: () => ({ + connected: true, + requestProperty: async (name) => { + if (name === 'secondary-sid') return 2; + if (name === 'track-list') return [{ type: 'sub', id: 2 }]; + if (name === 'path') return '/media/video.mkv'; + return null; + }, + }), + getCurrentTimePos: () => 2, + resolveSubtitleSource: async () => ({ path: '/subs/english.ass', sourceKey: 'english' }), + loadSubtitleSourceText: async () => '', + parseSubtitleCues: () => [], + setCurrentSecondaryText: () => {}, + broadcastSecondaryText: (text) => broadcasts.push(text), + }); + + await controller.refresh(); + broadcasts.length = 0; + controller.handleLiveText('Visible line\n\\\n{\\fr0'); + + assert.deepEqual(broadcasts, ['Visible line']); +}); + +test('secondary SRT live fallback preserves text that resembles ASS control debris', async () => { + const broadcasts: string[] = []; + const controller = createSecondarySubtitleTrackController({ + getMpvClient: () => ({ + connected: true, + requestProperty: async (name) => { + if (name === 'secondary-sid') return 2; + if (name === 'track-list') return [{ type: 'sub', id: 2 }]; + if (name === 'path') return '/media/video.mkv'; + return null; + }, + }), + getCurrentTimePos: () => 2, + resolveSubtitleSource: async () => ({ path: '/subs/english.srt', sourceKey: 'english' }), + loadSubtitleSourceText: async () => '', + parseSubtitleCues: () => [], + setCurrentSecondaryText: () => {}, + broadcastSecondaryText: (text) => broadcasts.push(text), + }); + + await controller.refresh(); + broadcasts.length = 0; + controller.handleLiveText('Visible line\n\\\n{\\fr0'); + + assert.deepEqual(broadcasts, ['Visible line\n\\\n{\\fr0']); +}); + +test('secondary disconnect clears stale ASS fallback sanitization state', async () => { + let connected = true; + const broadcasts: string[] = []; + const controller = createSecondarySubtitleTrackController({ + getMpvClient: () => ({ + connected, + requestProperty: async (name) => { + if (name === 'secondary-sid') return 2; + if (name === 'track-list') return [{ type: 'sub', id: 2 }]; + if (name === 'path') return '/media/video.mkv'; + return null; + }, + }), + getCurrentTimePos: () => 2, + resolveSubtitleSource: async () => ({ path: '/subs/english.ass', sourceKey: 'english' }), + loadSubtitleSourceText: async () => '', + parseSubtitleCues: () => [], + setCurrentSecondaryText: () => {}, + broadcastSecondaryText: (text) => broadcasts.push(text), + }); + + await controller.refresh(); + connected = false; + await controller.refresh(); + broadcasts.length = 0; + controller.handleLiveText('Visible line\n\\\n{\\fr0'); + + assert.deepEqual(broadcasts, ['Visible line\n\\\n{\\fr0']); +}); + +test('secondary source refresh failure clears stale ASS fallback sanitization state', async () => { + let resolveCalls = 0; + const broadcasts: string[] = []; + const controller = createSecondarySubtitleTrackController({ + getMpvClient: () => ({ + connected: true, + requestProperty: async (name) => { + if (name === 'secondary-sid') return 2; + if (name === 'track-list') return [{ type: 'sub', id: 2 }]; + if (name === 'path') return '/media/video.mkv'; + return null; + }, + }), + getCurrentTimePos: () => 2, + resolveSubtitleSource: async () => { + resolveCalls += 1; + if (resolveCalls === 1) { + return { path: '/subs/english.ass', sourceKey: 'english' }; + } + throw new Error('source refresh failed'); + }, + loadSubtitleSourceText: async () => '', + parseSubtitleCues: () => [], + setCurrentSecondaryText: () => {}, + broadcastSecondaryText: (text) => broadcasts.push(text), + }); + + await controller.refresh(); + await controller.refresh(); + broadcasts.length = 0; + controller.handleLiveText('Visible line\n\\\n{\\fr0'); + + assert.deepEqual(broadcasts, ['Visible line\n\\\n{\\fr0']); +}); + +test('secondary track controller reuses parsed cues for an unchanged embedded track', async () => { + let resolveCalls = 0; + let parseCalls = 0; + const controller = createSecondarySubtitleTrackController({ + getMpvClient: () => ({ + connected: true, + requestProperty: async (name) => { + if (name === 'secondary-sid') return 2; + if (name === 'track-list') { + return [{ type: 'sub', id: 2, external: false, 'ff-index': 3 }]; + } + if (name === 'path') return '/media/video.mkv'; + if (name === 'secondary-sub-delay') return 0; + return null; + }, + }), + getCurrentTimePos: () => 2, + resolveSubtitleSource: async () => { + resolveCalls += 1; + return { path: `/tmp/extracted-${resolveCalls}.ass`, sourceKey: 'embedded-track-2' }; + }, + loadSubtitleSourceText: async () => '', + parseSubtitleCues: () => { + parseCalls += 1; + return [{ startTime: 1, endTime: 3, text: 'parsed' }]; + }, + setCurrentSecondaryText: () => {}, + broadcastSecondaryText: () => {}, + }); + + await controller.refresh(); + await controller.refresh(); + + assert.equal(resolveCalls, 1); + assert.equal(parseCalls, 1); +}); + +test('secondary track controller ignores and cleans up a refresh invalidated by reset', async () => { + const broadcasts: string[] = []; + let notifyResolveStarted: (() => void) | undefined; + let releaseResolve: (() => void) | undefined; + let cleanupCalls = 0; + let parseCalls = 0; + const resolveStarted = new Promise((resolve) => { + notifyResolveStarted = resolve; + }); + const resolveGate = new Promise((resolve) => { + releaseResolve = resolve; + }); + const controller = createSecondarySubtitleTrackController({ + getMpvClient: () => ({ + connected: true, + requestProperty: async (name) => { + if (name === 'secondary-sid') return 2; + if (name === 'track-list') return [{ type: 'sub', id: 2, external: true }]; + if (name === 'path') return '/media/video.mkv'; + if (name === 'secondary-sub-delay') return 0; + return null; + }, + }), + getCurrentTimePos: () => 2, + resolveSubtitleSource: async () => { + notifyResolveStarted?.(); + await resolveGate; + return { + path: '/subs/secondary.ass', + sourceKey: 'secondary', + cleanup: async () => { + cleanupCalls += 1; + }, + }; + }, + loadSubtitleSourceText: async () => '', + parseSubtitleCues: () => { + parseCalls += 1; + return [{ startTime: 1, endTime: 3, text: 'stale' }]; + }, + setCurrentSecondaryText: () => {}, + broadcastSecondaryText: (text) => broadcasts.push(text), + }); + + const refresh = controller.refresh(); + await resolveStarted; + controller.reset(); + releaseResolve?.(); + await refresh; + + assert.deepEqual(broadcasts, ['']); + assert.equal(parseCalls, 0); + assert.equal(cleanupCalls, 1); +}); + +test('secondary live fallback suppresses a per-glyph typesetting wall', async () => { + let currentText = ''; + const controller = createSecondarySubtitleTrackController({ + getMpvClient: () => ({ + connected: true, + requestProperty: async (name) => { + if (name === 'secondary-sid') return 2; + if (name === 'track-list') return [{ type: 'sub', id: 2 }]; + if (name === 'path') return '/mnt/nas/video.mkv'; + if (name === 'secondary-sub-delay') return 0; + return null; + }, + }), + getCurrentTimePos: () => 1355, + // Network-mounted media: embedded extraction is skipped, so no parsed cues exist. + resolveSubtitleSource: async () => null, + loadSubtitleSourceText: async () => '', + parseSubtitleCues, + setCurrentSecondaryText: (text) => { + currentText = text; + }, + broadcastSecondaryText: () => {}, + }); + + await controller.refresh(); + const wall = [...'wansdumretoikhI'].join('\n'); + controller.handleLiveText(`${wall}\ntai`); + assert.equal(currentText, ''); + + controller.handleLiveText(`${wall}\nそれよりも ノート…`); + assert.equal(currentText, 'それよりも ノート…'); +}); diff --git a/src/main/runtime/secondary-subtitle-track.ts b/src/main/runtime/secondary-subtitle-track.ts new file mode 100644 index 00000000..f6b076b9 --- /dev/null +++ b/src/main/runtime/secondary-subtitle-track.ts @@ -0,0 +1,366 @@ +import type { SubtitleCue } from '../../types/subtitle'; +import { flattenedSecondarySubtitleLineIdentity } from '../../core/services/secondary-subtitle-line-identity'; +import { + removeAssControlDebrisLines, + removeLiveGlyphFragmentLines, +} from '../../core/services/ass-text'; + +type SecondarySubtitleMpvClient = { + connected?: boolean; + requestProperty: (name: string) => Promise; +}; + +type ResolvedSubtitleSource = { + path: string; + sourceKey: string; + cleanup?: () => Promise; +}; + +type SecondarySubtitleSourceInput = { + currentExternalFilenameRaw: unknown; + currentTrackRaw: unknown; + trackListRaw: unknown; + sidRaw: unknown; + videoPath: string; + allowSelectedFallback?: boolean; +}; + +const DEFAULT_REFRESH_DELAY_MS = 500; + +function sourceUsesAssSyntax(source: string): boolean { + const sourceWithoutQuery = source.split(/[?#]/u, 1)[0] ?? ''; + return /\.(?:ass|ssa)$/iu.test(sourceWithoutQuery); +} + +function finiteNumber(value: unknown, fallback = 0): number { + const number = typeof value === 'number' ? value : Number(value); + return Number.isFinite(number) ? number : fallback; +} + +function trackId(value: unknown): number | null { + if (typeof value !== 'number' && typeof value !== 'string') return null; + const number = typeof value === 'number' ? value : Number(value.trim()); + return Number.isInteger(number) ? number : null; +} + +function buildSelectedTrackIdentity( + trackListRaw: unknown, + sidRaw: unknown, + videoPath: string, +): string | null { + if (!Array.isArray(trackListRaw)) return null; + const sid = trackId(sidRaw); + if (sid === null) return null; + + const selectedTrack = trackListRaw.find((entry: unknown) => { + if (!entry || typeof entry !== 'object') return false; + const track = entry as Record; + return track.type === 'sub' && trackId(track.id) === sid; + }) as Record | undefined; + if (!selectedTrack) return null; + + return JSON.stringify([ + videoPath, + sid, + selectedTrack.external === true, + selectedTrack['external-filename'] ?? null, + trackId(selectedTrack['ff-index']), + ]); +} + +type IndexedSubtitleCue = { cue: SubtitleCue; index: number }; + +function compareAuthoredSubtitleOrder(left: IndexedSubtitleCue, right: IndexedSubtitleCue): number { + const leftLayout = left.cue.assLayout; + const rightLayout = right.cue.assLayout; + if (leftLayout?.kind === 'positioned' && rightLayout?.kind === 'positioned') { + const verticalOrder = leftLayout.y - rightLayout.y; + if (verticalOrder !== 0) return verticalOrder; + } + if (leftLayout && rightLayout) { + const sourceOrder = leftLayout.sourceOrder - rightLayout.sourceOrder; + if (sourceOrder !== 0) return sourceOrder; + } + return left.index - right.index; +} + +export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds: number): string { + if (!Number.isFinite(timeSeconds)) return ''; + + const authoredCanonical = cues.filter( + (cue) => + cue.source === 'canonical-ass' && cue.startTime <= timeSeconds && cue.endTime > timeSeconds, + ); + const enteringCanonical = cues.filter( + (cue) => + cue.source === 'canonical-ass' && + (cue.animationStartTime ?? cue.startTime) <= timeSeconds && + cue.startTime > timeSeconds && + (cue.animationEndTime ?? cue.endTime) > timeSeconds, + ); + const nextAuthoredStart = enteringCanonical.reduce( + (earliest, cue) => Math.min(earliest, cue.startTime), + Infinity, + ); + // Generated lyrics can begin drawing before their canonical Comment timing. Once that + // entrance starts, replace a preceding lyric that ends before the new authored span; + // genuinely concurrent subtitles that continue through the new span stay selected. + const selectedCanonical = new Set([ + ...authoredCanonical.filter( + (cue) => enteringCanonical.length === 0 || cue.endTime > nextAuthoredStart, + ), + ...enteringCanonical, + ]); + if (selectedCanonical.size === 0) { + const animatedCanonical = cues.filter( + (cue) => + cue.source === 'canonical-ass' && + (cue.animationStartTime ?? cue.startTime) <= timeSeconds && + (cue.animationEndTime ?? cue.endTime) > timeSeconds, + ); + const nearestDistance = animatedCanonical.reduce((nearest, cue) => { + const distance = + timeSeconds < cue.startTime + ? cue.startTime - timeSeconds + : Math.max(0, timeSeconds - cue.endTime); + return Math.min(nearest, distance); + }, Infinity); + for (const cue of animatedCanonical) { + const distance = + timeSeconds < cue.startTime + ? cue.startTime - timeSeconds + : Math.max(0, timeSeconds - cue.endTime); + if (distance === nearestDistance) { + selectedCanonical.add(cue); + } + } + } + + const activeReconstructed = cues.filter( + (cue) => + cue.source === 'reconstructed-ass' && + cue.assLayout?.kind !== 'fragment-grid' && + cue.startTime <= timeSeconds && + cue.endTime > timeSeconds, + ); + const reconstructedByStyle = new Map(); + for (const cue of activeReconstructed) { + const style = cue.assStyle ?? ''; + const existing = reconstructedByStyle.get(style); + if (!existing) { + reconstructedByStyle.set(style, cue); + continue; + } + const duration = cue.endTime - cue.startTime; + const existingDuration = existing.endTime - existing.startTime; + if ( + duration > existingDuration || + (duration === existingDuration && cue.text.length > existing.text.length) || + (duration === existingDuration && + cue.text.length === existing.text.length && + cue.startTime > existing.startTime) + ) { + reconstructedByStyle.set(style, cue); + } + } + const selectedReconstructed = new Set(reconstructedByStyle.values()); + + const seenExact = new Set(); + const seenFlattened = new Set(); + const activeText: string[] = []; + const activeCues: IndexedSubtitleCue[] = []; + cues.forEach((cue, index) => { + const active = + cue.source === 'canonical-ass' + ? selectedCanonical.has(cue) + : cue.source === 'reconstructed-ass' + ? selectedReconstructed.has(cue) + : cue.startTime <= timeSeconds && cue.endTime > timeSeconds; + if (active) activeCues.push({ cue, index }); + }); + activeCues.sort(compareAuthoredSubtitleOrder); + + for (const { cue } of activeCues) { + for (const line of cue.text.split('\n')) { + const text = line.trim(); + const compactText = text.normalize('NFKC').replace(/\s+/gu, ''); + if (!compactText || seenExact.has(compactText)) continue; + seenExact.add(compactText); + + const flattenedIdentity = flattenedSecondarySubtitleLineIdentity(text); + if (flattenedIdentity && seenFlattened.has(flattenedIdentity)) continue; + if (flattenedIdentity) seenFlattened.add(flattenedIdentity); + activeText.push(text); + } + } + return activeText.join('\n'); +} + +export function createSecondarySubtitleTrackController(deps: { + getMpvClient: () => SecondarySubtitleMpvClient | null; + getCurrentTimePos: () => number; + resolveSubtitleSource: ( + input: SecondarySubtitleSourceInput, + ) => Promise; + loadSubtitleSourceText: (source: string) => Promise; + parseSubtitleCues: (content: string, filename: string) => SubtitleCue[]; + setCurrentSecondaryText: (text: string) => void; + broadcastSecondaryText: (text: string) => void; + logDebug?: (message: string) => void; + logWarn?: (message: string, error: unknown) => void; +}) { + let parsedCues: SubtitleCue[] | null = null; + let parsedSourceKey: string | null = null; + let parsedTrackIdentity: string | null = null; + let activeSourceUsesAssSyntax = false; + let secondaryDelaySeconds = 0; + let lastLiveText = ''; + let lastBroadcastText: string | null = null; + let refreshGeneration = 0; + let refreshTimer: ReturnType | null = null; + + const publish = (text: string): void => { + deps.setCurrentSecondaryText(text); + if (text === lastBroadcastText) return; + lastBroadcastText = text; + deps.broadcastSecondaryText(text); + }; + + const resolveAtTime = (timeSeconds: number): string => { + if (!parsedCues) return lastLiveText; + return findActiveSubtitleText(parsedCues, timeSeconds - secondaryDelaySeconds); + }; + + const useLiveFallback = (): void => { + parsedCues = null; + parsedSourceKey = null; + parsedTrackIdentity = null; + publish(lastLiveText); + }; + + const refresh = async (): Promise => { + const generation = ++refreshGeneration; + const client = deps.getMpvClient(); + if (!client?.connected) { + activeSourceUsesAssSyntax = false; + useLiveFallback(); + return; + } + + let resolvedSource: ResolvedSubtitleSource | null = null; + try { + const [secondarySid, trackList, videoPathRaw, secondaryDelayRaw] = await Promise.all([ + client.requestProperty('secondary-sid').catch(() => null), + client.requestProperty('track-list').catch(() => null), + client.requestProperty('path').catch(() => null), + client.requestProperty('secondary-sub-delay').catch(() => 0), + ]); + if (generation !== refreshGeneration) return; + + const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw.trim() : ''; + if (!videoPath || secondarySid === null || secondarySid === 'no') { + activeSourceUsesAssSyntax = false; + useLiveFallback(); + return; + } + + secondaryDelaySeconds = finiteNumber(secondaryDelayRaw); + const selectedTrackIdentity = buildSelectedTrackIdentity(trackList, secondarySid, videoPath); + if (selectedTrackIdentity && selectedTrackIdentity === parsedTrackIdentity && parsedCues) { + publish(resolveAtTime(deps.getCurrentTimePos())); + return; + } + + resolvedSource = await deps.resolveSubtitleSource({ + currentExternalFilenameRaw: null, + currentTrackRaw: null, + trackListRaw: trackList, + sidRaw: secondarySid, + videoPath, + allowSelectedFallback: false, + }); + if (generation !== refreshGeneration) return; + if (!resolvedSource) { + activeSourceUsesAssSyntax = false; + deps.logDebug?.('[secondary-subtitle-track] selected source is not readable'); + useLiveFallback(); + return; + } + + activeSourceUsesAssSyntax = sourceUsesAssSyntax(resolvedSource.path); + + if (resolvedSource.sourceKey === parsedSourceKey && parsedCues) { + parsedTrackIdentity = selectedTrackIdentity; + publish(resolveAtTime(deps.getCurrentTimePos())); + return; + } + + const content = await deps.loadSubtitleSourceText(resolvedSource.path); + const cues = deps.parseSubtitleCues(content, resolvedSource.path); + if (generation !== refreshGeneration) return; + if (cues.length === 0) { + deps.logDebug?.('[secondary-subtitle-track] selected source contained no parsed cues'); + useLiveFallback(); + return; + } + + parsedCues = cues; + parsedSourceKey = resolvedSource.sourceKey; + parsedTrackIdentity = selectedTrackIdentity; + publish(resolveAtTime(deps.getCurrentTimePos())); + } catch (error) { + if (generation !== refreshGeneration) return; + activeSourceUsesAssSyntax = false; + deps.logWarn?.('[secondary-subtitle-track] failed to parse selected source', error); + useLiveFallback(); + } finally { + await resolvedSource?.cleanup?.().catch(() => undefined); + } + }; + + const scheduleRefresh = (delayMs = DEFAULT_REFRESH_DELAY_MS): void => { + if (refreshTimer) clearTimeout(refreshTimer); + refreshTimer = setTimeout(() => { + refreshTimer = null; + void refresh(); + }, delayMs); + }; + + const clearSelectedTrack = (): void => { + refreshGeneration += 1; + if (refreshTimer) clearTimeout(refreshTimer); + refreshTimer = null; + parsedCues = null; + parsedSourceKey = null; + parsedTrackIdentity = null; + activeSourceUsesAssSyntax = false; + secondaryDelaySeconds = 0; + lastLiveText = ''; + publish(''); + }; + + return { + refresh, + scheduleRefresh, + handleLiveText(text: string): void { + lastLiveText = removeLiveGlyphFragmentLines( + activeSourceUsesAssSyntax ? removeAssControlDebrisLines(text) : text, + ); + publish(resolveAtTime(deps.getCurrentTimePos())); + }, + handleTimePos(timeSeconds: number): void { + if (!parsedCues) return; + publish(resolveAtTime(timeSeconds)); + }, + handleTrackChange(): void { + clearSelectedTrack(); + }, + handleDelayChange(delaySeconds: number): void { + secondaryDelaySeconds = finiteNumber(delaySeconds); + if (parsedCues) { + publish(resolveAtTime(deps.getCurrentTimePos())); + } + }, + reset: clearSelectedTrack, + }; +} diff --git a/src/main/runtime/subtitle-prefetch-runtime.test.ts b/src/main/runtime/subtitle-prefetch-runtime.test.ts index 385b3365..c0c6fca7 100644 --- a/src/main/runtime/subtitle-prefetch-runtime.test.ts +++ b/src/main/runtime/subtitle-prefetch-runtime.test.ts @@ -101,6 +101,32 @@ test('subtitle prefetch runtime preserves parsed cues when YouTube active track assert.deepEqual(calls, []); }); +test('subtitle prefetch runtime preserves parsed cues when a network mount source is unresolved', async () => { + const calls: string[] = []; + const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({ + getMpvClient: () => ({ + connected: true, + requestProperty: async (name) => (name === 'path' ? '/Volumes/jellyfin/movie.mkv' : null), + }), + getLastObservedTimePos: () => 12, + subtitlePrefetchInitController: { + cancelPendingInit: () => { + calls.push('cancel'); + }, + initSubtitlePrefetch: async () => { + calls.push('init'); + }, + }, + resolveActiveSubtitleSidebarSource: async () => null, + shouldKeepExistingCuesOnMissingSource: async (videoPath) => + videoPath.startsWith('/Volumes/jellyfin/'), + }); + + await refresh(); + + assert.deepEqual(calls, []); +}); + test('subtitle prefetch runtime does not extract internal subtitle tracks from remote media urls', async () => { let extracted = false; const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({ @@ -131,6 +157,36 @@ test('subtitle prefetch runtime does not extract internal subtitle tracks from r assert.equal(extracted, false); }); +test('subtitle prefetch runtime extracts internal subtitle tracks from network-mounted media', async () => { + let extracted = false; + const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({ + getFfmpegPath: () => 'ffmpeg-custom', + extractInternalSubtitleTrack: async () => { + extracted = true; + return { + path: '/tmp/subminer-sidebar-123/track_7.ass', + cleanup: async () => {}, + }; + }, + }); + + const resolved = await resolveSource({ + currentExternalFilenameRaw: null, + currentTrackRaw: { + type: 'sub', + id: 3, + 'ff-index': 7, + codec: 'ass', + }, + trackListRaw: [], + sidRaw: 3, + videoPath: '/Volumes/jellyfin/movie.mkv', + }); + + assert.equal(resolved?.path, '/tmp/subminer-sidebar-123/track_7.ass'); + assert.equal(extracted, true); +}); + test('subtitle prefetch refresh logs a warning when source resolution throws', async () => { const warnings: string[] = []; const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({ @@ -248,3 +304,31 @@ test('subtitle source resolver logs debug when no active subtitle track is selec assert.equal(debugs.length, 1); assert.match(debugs[0]!, /\[subtitle-prefetch\].*no active subtitle track/); }); + +test('subtitle source resolver does not fall back to the primary selected track for secondary', async () => { + const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({ + getFfmpegPath: () => 'ffmpeg', + extractInternalSubtitleTrack: async () => { + throw new Error('should not extract the primary track'); + }, + }); + + const resolved = await resolveSource({ + currentExternalFilenameRaw: null, + currentTrackRaw: null, + trackListRaw: [ + { + type: 'sub', + id: 1, + selected: true, + external: true, + 'external-filename': '/subs/primary.ass', + }, + ], + sidRaw: null, + videoPath: '/media/video.mkv', + allowSelectedFallback: false, + }); + + assert.equal(resolved, null); +}); diff --git a/src/main/runtime/subtitle-prefetch-runtime.ts b/src/main/runtime/subtitle-prefetch-runtime.ts index 91ec827c..d759beff 100644 --- a/src/main/runtime/subtitle-prefetch-runtime.ts +++ b/src/main/runtime/subtitle-prefetch-runtime.ts @@ -28,7 +28,7 @@ function parseTrackId(value: unknown): number | null { return null; } -function isRemoteMediaPath(value: string): boolean { +function isRemoteMediaUrl(value: string): boolean { try { const url = new URL(value); return url.protocol === 'http:' || url.protocol === 'https:'; @@ -41,6 +41,7 @@ function getActiveSubtitleTrack( currentTrackRaw: unknown, trackListRaw: unknown, sidRaw: unknown, + allowSelectedFallback: boolean, ): MpvSubtitleTrackLike | null { if (currentTrackRaw && typeof currentTrackRaw === 'object') { const track = currentTrackRaw as MpvSubtitleTrackLike; @@ -68,6 +69,10 @@ function getActiveSubtitleTrack( return bySid; } + if (!allowSelectedFallback) { + return null; + } + return ( (trackListRaw.find((entry: unknown) => { if (!entry || typeof entry !== 'object') { @@ -94,6 +99,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: { trackListRaw: unknown; sidRaw: unknown; videoPath: string; + allowSelectedFallback?: boolean; }): Promise => { const currentExternalFilename = typeof input.currentExternalFilenameRaw === 'string' @@ -103,7 +109,12 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: { return { path: currentExternalFilename, sourceKey: currentExternalFilename }; } - const track = getActiveSubtitleTrack(input.currentTrackRaw, input.trackListRaw, input.sidRaw); + const track = getActiveSubtitleTrack( + input.currentTrackRaw, + input.trackListRaw, + input.sidRaw, + input.allowSelectedFallback !== false, + ); if (!track) { deps.logDebug?.('[subtitle-prefetch] no active subtitle track selected yet'); return null; @@ -115,7 +126,10 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: { return { path: externalFilename, sourceKey: externalFilename }; } - if (isRemoteMediaPath(input.videoPath)) { + // Network-mounted files extract like local ones: demuxing reads the whole + // container (~10s/GB on gigabit), which a LAN handles alongside playback. + // Only true remote URLs have no on-disk container to demux. + if (isRemoteMediaUrl(input.videoPath)) { deps.logDebug?.('[subtitle-prefetch] skipping internal subtitle extraction for remote media'); return null; } @@ -145,7 +159,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: { requestProperty: (name: string) => Promise; } | null; getLastObservedTimePos: () => number; - shouldKeepExistingCuesOnMissingSource?: (videoPath: string) => boolean; + shouldKeepExistingCuesOnMissingSource?: (videoPath: string) => boolean | Promise; subtitlePrefetchInitController: SubtitlePrefetchInitController; resolveActiveSubtitleSidebarSource: ( input: Parameters>[0], @@ -184,7 +198,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: { videoPath, }); if (!resolvedSource) { - if (deps.shouldKeepExistingCuesOnMissingSource?.(videoPath) === true) { + if ((await deps.shouldKeepExistingCuesOnMissingSource?.(videoPath)) === true) { deps.logDebug?.( '[subtitle-prefetch] no active subtitle source resolved; keeping existing cues', ); diff --git a/src/main/runtime/update/support-assets.test.ts b/src/main/runtime/update/support-assets.test.ts index 16b23ea9..702cafa7 100644 --- a/src/main/runtime/update/support-assets.test.ts +++ b/src/main/runtime/update/support-assets.test.ts @@ -13,7 +13,7 @@ import { } from './support-assets'; type SupportAssetsResultWithComponent = SupportAssetsUpdateResult & { - component?: 'theme' | 'plugin'; + component?: 'theme' | 'thumbnailer' | 'plugin'; }; function sha256(data: Buffer): string { @@ -22,18 +22,30 @@ function sha256(data: Buffer): string { function makeSupportAssetsArchive(options?: { themeContent?: string; + thumbnailerContent?: string; + includeThumbnailer?: boolean; pluginVersion?: string | null; pluginMainContent?: string; extraPluginFiles?: Array<{ relativePath: string; content: string }>; }): { archive: Buffer; tempDir: string } { const themeContent = options?.themeContent ?? 'new theme\n'; + const thumbnailerContent = options?.thumbnailerContent ?? '[Thumbnailer Entry]\n'; const pluginVersion = options && 'pluginVersion' in options ? options.pluginVersion : '0.12.0'; const pluginMainContent = options?.pluginMainContent ?? 'new plugin\n'; const extraPluginFiles = options?.extraPluginFiles ?? []; const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-support-assets-test-')); fs.mkdirSync(path.join(tempDir, 'assets/themes'), { recursive: true }); + if (options?.includeThumbnailer !== false) { + fs.mkdirSync(path.join(tempDir, 'assets/thumbnailers'), { recursive: true }); + } fs.mkdirSync(path.join(tempDir, 'plugin/subminer'), { recursive: true }); fs.writeFileSync(path.join(tempDir, 'assets/themes/subminer.rasi'), themeContent); + if (options?.includeThumbnailer !== false) { + fs.writeFileSync( + path.join(tempDir, 'assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer'), + thumbnailerContent, + ); + } fs.writeFileSync(path.join(tempDir, 'plugin/subminer/main.lua'), pluginMainContent); if (pluginVersion !== null) { fs.writeFileSync( @@ -93,7 +105,7 @@ test('detectSupportAssetDataDirs only returns Linux support-asset locations', () ); }); -test('buildProtectedSupportAssetsCommand installs both theme and plugin assets', () => { +test('buildProtectedSupportAssetsCommand installs theme, thumbnailer, and plugin assets', () => { const command = buildProtectedSupportAssetsCommand( "https://example.test/subminer assets.tar.gz?sig='abc'", 'ABCDEF1234', @@ -110,11 +122,28 @@ test('buildProtectedSupportAssetsCommand installs both theme and plugin assets', command, /printf '%s %s\\n' 'abcdef1234' "\$tmp\/subminer-assets\.tar\.gz" \| sha256sum -c -/, ); + const requiredAssetChecks = [ + 'test -f "$tmp/assets/themes/subminer.rasi"', + 'test -f "$tmp/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer"', + 'test -f "$tmp/plugin/subminer/main.lua"', + 'test -f "$tmp/plugin/subminer/version.lua"', + ]; + const firstSudoIndex = command.indexOf('sudo '); + assert.notEqual(firstSudoIndex, -1); + for (const check of requiredAssetChecks) { + const checkIndex = command.indexOf(check); + assert.notEqual(checkIndex, -1); + assert.ok(checkIndex < firstSudoIndex); + } assert.match(command, /sudo mkdir -p '\/usr\/local\/share\/SubMiner'\\''s data'\/themes/); assert.match( command, /sudo cp "\$tmp\/assets\/themes\/subminer\.rasi" '\/usr\/local\/share\/SubMiner'\\''s data'\/themes\/subminer\.rasi/, ); + assert.match( + command, + /sudo cp "\$tmp\/assets\/thumbnailers\/subminer-ffmpegthumbnailer\.thumbnailer" .*thumbnailers\/subminer-ffmpegthumbnailer\.thumbnailer/, + ); assert.match(command, /sudo mkdir -p '\/usr\/local\/share\/SubMiner'\\''s data'\/plugin/); assert.match(command, /sudo rm -rf .*plugin\/subminer\.next/); assert.match(command, /sudo cp -R "\$tmp\/plugin\/subminer" .*plugin\/subminer\.next/); @@ -209,6 +238,12 @@ test('updateSupportAssetsFromRelease installs missing plugin into a root with a path: dataDir, message: 'Updated theme.', }, + { + status: 'updated', + component: 'thumbnailer', + path: dataDir, + message: 'Installed rofi thumbnailer.', + }, { status: 'updated', component: 'plugin', @@ -220,6 +255,13 @@ test('updateSupportAssetsFromRelease installs missing plugin into a root with a fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'), 'new theme\n', ); + assert.equal( + fs.readFileSync( + path.join(dataDir, 'thumbnailers/subminer-ffmpegthumbnailer.thumbnailer'), + 'utf8', + ), + '[Thumbnailer Entry]\n', + ); assert.equal( fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'), 'new plugin\n', @@ -345,8 +387,13 @@ test('updateSupportAssetsFromRelease skips identical theme and up-to-date plugin const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-')); const dataDir = path.posix.join(xdgDataHome, 'SubMiner'); fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true }); + fs.mkdirSync(path.join(dataDir, 'thumbnailers'), { recursive: true }); fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true }); fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'same theme\n'); + fs.writeFileSync( + path.join(dataDir, 'thumbnailers/subminer-ffmpegthumbnailer.thumbnailer'), + '[Thumbnailer Entry]\n', + ); fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'same plugin\n'); fs.writeFileSync( path.join(dataDir, 'plugin/subminer/version.lua'), @@ -371,6 +418,12 @@ test('updateSupportAssetsFromRelease skips identical theme and up-to-date plugin path: dataDir, message: 'Theme already up to date.', }, + { + status: 'skipped', + component: 'thumbnailer', + path: dataDir, + message: 'Rofi thumbnailer already up to date.', + }, { status: 'skipped', component: 'plugin', @@ -396,8 +449,13 @@ test('updateSupportAssetsFromRelease updates changed theme and outdated plugin w const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-')); const dataDir = path.posix.join(xdgDataHome, 'SubMiner'); fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true }); + fs.mkdirSync(path.join(dataDir, 'thumbnailers'), { recursive: true }); fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true }); fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'old theme\n'); + fs.writeFileSync( + path.join(dataDir, 'thumbnailers/subminer-ffmpegthumbnailer.thumbnailer'), + '[Old Thumbnailer]\n', + ); fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'old plugin\n'); fs.writeFileSync( path.join(dataDir, 'plugin/subminer/version.lua'), @@ -406,6 +464,7 @@ test('updateSupportAssetsFromRelease updates changed theme and outdated plugin w fs.writeFileSync(path.join(dataDir, 'plugin/subminer/stale.lua'), 'stale\n'); const { archive, tempDir } = makeSupportAssetsArchive({ themeContent: 'new theme\n', + thumbnailerContent: '[Thumbnailer Entry]\n', pluginVersion: '0.12.0', pluginMainContent: 'new plugin main\n', extraPluginFiles: [{ relativePath: 'fresh.lua', content: 'fresh\n' }], @@ -424,6 +483,12 @@ test('updateSupportAssetsFromRelease updates changed theme and outdated plugin w path: dataDir, message: 'Updated theme.', }, + { + status: 'updated', + component: 'thumbnailer', + path: dataDir, + message: 'Updated rofi thumbnailer.', + }, { status: 'updated', component: 'plugin', @@ -435,6 +500,13 @@ test('updateSupportAssetsFromRelease updates changed theme and outdated plugin w fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'), 'new theme\n', ); + assert.equal( + fs.readFileSync( + path.join(dataDir, 'thumbnailers/subminer-ffmpegthumbnailer.thumbnailer'), + 'utf8', + ), + '[Thumbnailer Entry]\n', + ); assert.equal( fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'), 'new plugin main\n', @@ -479,6 +551,12 @@ test('updateSupportAssetsFromRelease returns protected commands for managed root path: dataDir, command: true, }, + { + status: 'protected', + component: 'thumbnailer', + path: dataDir, + command: true, + }, { status: 'protected', component: 'plugin', @@ -488,6 +566,10 @@ test('updateSupportAssetsFromRelease returns protected commands for managed root ], ); assert.match(results[0]?.command ?? '', /themes\/subminer\.rasi/); + assert.match( + results[0]?.command ?? '', + /thumbnailers\/subminer-ffmpegthumbnailer\.thumbnailer/, + ); assert.match(results[0]?.command ?? '', /plugin\/subminer/); } finally { fs.chmodSync(dataDir, originalMode); @@ -522,3 +604,25 @@ test('updateSupportAssetsFromRelease returns missing-asset when release plugin v fs.rmSync(tempDir, { recursive: true, force: true }); } }); + +test('updateSupportAssetsFromRelease rejects archives without the rofi thumbnailer', async () => { + const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-')); + const dataDir = path.posix.join(xdgDataHome, 'SubMiner'); + fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true }); + fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'managed theme\n'); + const { archive, tempDir } = makeSupportAssetsArchive({ includeThumbnailer: false }); + + try { + const results = await runLinuxSupportAssetUpdate({ archive, xdgDataHome }); + + assert.deepEqual(results, [ + { + status: 'missing-asset', + message: 'Support asset archive is missing the rofi thumbnailer.', + }, + ]); + } finally { + fs.rmSync(xdgDataHome, { recursive: true, force: true }); + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/src/main/runtime/update/support-assets.ts b/src/main/runtime/update/support-assets.ts index dfbdd800..9b4954a0 100644 --- a/src/main/runtime/update/support-assets.ts +++ b/src/main/runtime/update/support-assets.ts @@ -9,13 +9,17 @@ import { compareSemverLike, findReleaseAsset } from './release-assets'; const execFileAsync = promisify(execFile); const THEME_RELATIVE_PATH = path.join('themes', 'subminer.rasi'); +const THUMBNAILER_RELATIVE_PATH = path.join( + 'thumbnailers', + 'subminer-ffmpegthumbnailer.thumbnailer', +); const PLUGIN_ENTRYPOINT_RELATIVE_PATH = path.join('plugin', 'subminer', 'main.lua'); const PLUGIN_VERSION_RELATIVE_PATH = path.join('plugin', 'subminer', 'version.lua'); const PLUGIN_DIR_RELATIVE_PATH = path.join('plugin', 'subminer'); export interface SupportAssetsUpdateResult { status: 'updated' | 'skipped' | 'protected' | 'hash-mismatch' | 'missing-asset'; - component?: 'theme' | 'plugin'; + component?: 'theme' | 'thumbnailer' | 'plugin'; path?: string; command?: string; message?: string; @@ -69,11 +73,12 @@ async function readInstalledPluginVersion(pluginDir: string): Promise { const managedDataDirs: string[] = []; for (const dataDir of dataDirs) { - const [hasTheme, hasPlugin] = await Promise.all([ + const [hasTheme, hasThumbnailer, hasPlugin] = await Promise.all([ pathExists(path.join(dataDir, THEME_RELATIVE_PATH)), + pathExists(path.join(dataDir, THUMBNAILER_RELATIVE_PATH)), pathExists(path.join(dataDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH)), ]); - if (hasTheme || hasPlugin) { + if (hasTheme || hasThumbnailer || hasPlugin) { managedDataDirs.push(dataDir); } } @@ -163,8 +168,14 @@ export function buildProtectedSupportAssetsCommand( `curl -fSL ${shellQuote(assetUrl)} -o "$tmp/subminer-assets.tar.gz"`, `printf '%s %s\\n' ${quotedExpectedSha256} "$tmp/subminer-assets.tar.gz" | sha256sum -c -`, 'tar -xzf "$tmp/subminer-assets.tar.gz" -C "$tmp"', + 'test -f "$tmp/assets/themes/subminer.rasi"', + 'test -f "$tmp/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer"', + 'test -f "$tmp/plugin/subminer/main.lua"', + 'test -f "$tmp/plugin/subminer/version.lua"', `sudo mkdir -p ${quotedDir}/themes`, `sudo cp "$tmp/assets/themes/subminer.rasi" ${quotedDir}/themes/subminer.rasi`, + `sudo mkdir -p ${quotedDir}/thumbnailers`, + `sudo cp "$tmp/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer" ${quotedDir}/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer`, `sudo mkdir -p ${quotedDir}/plugin`, `sudo rm -rf ${quotedStagedPluginDir} ${quotedBackupPluginDir}`, `sudo cp -R "$tmp/plugin/subminer" ${quotedStagedPluginDir}`, @@ -216,6 +227,12 @@ export async function updateSupportAssetsFromRelease(options: { dataDir, 'Support asset path is not a directory.', ), + makeSupportAssetResult( + 'skipped', + 'thumbnailer', + dataDir, + 'Support asset path is not a directory.', + ), makeSupportAssetResult( 'skipped', 'plugin', @@ -244,6 +261,13 @@ export async function updateSupportAssetsFromRelease(options: { 'Theme install requires a manual command.', command, ), + makeSupportAssetResult( + 'protected', + 'thumbnailer', + dataDir, + 'Rofi thumbnailer install requires a manual command.', + command, + ), makeSupportAssetResult( 'protected', 'plugin', @@ -284,6 +308,17 @@ export async function updateSupportAssetsFromRelease(options: { } const themeBytes = await fs.promises.readFile(themeSourcePath); + const thumbnailerSourcePath = path.join(tempDir, 'assets', THUMBNAILER_RELATIVE_PATH); + if (!(await pathExists(thumbnailerSourcePath))) { + return [ + { + status: 'missing-asset', + message: 'Support asset archive is missing the rofi thumbnailer.', + }, + ]; + } + const thumbnailerBytes = await fs.promises.readFile(thumbnailerSourcePath); + const sourcePluginDir = path.join(tempDir, PLUGIN_DIR_RELATIVE_PATH); const sourcePluginEntrypoint = path.join(tempDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH); if (!(await pathExists(sourcePluginEntrypoint))) { @@ -328,6 +363,33 @@ export async function updateSupportAssetsFromRelease(options: { ); } + const targetThumbnailerPath = path.join(dataDir, THUMBNAILER_RELATIVE_PATH); + const existingThumbnailerBytes = await readFileIfExists(targetThumbnailerPath); + if ( + existingThumbnailerBytes && + Buffer.compare(existingThumbnailerBytes, thumbnailerBytes) === 0 + ) { + results.push( + makeSupportAssetResult( + 'skipped', + 'thumbnailer', + dataDir, + 'Rofi thumbnailer already up to date.', + ), + ); + } else { + await fs.promises.mkdir(path.dirname(targetThumbnailerPath), { recursive: true }); + await fs.promises.writeFile(targetThumbnailerPath, thumbnailerBytes); + results.push( + makeSupportAssetResult( + 'updated', + 'thumbnailer', + dataDir, + existingThumbnailerBytes ? 'Updated rofi thumbnailer.' : 'Installed rofi thumbnailer.', + ), + ); + } + const targetPluginDir = path.join(dataDir, PLUGIN_DIR_RELATIVE_PATH); const targetPluginEntrypoint = path.join(dataDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH); const installedPluginVersion = await readInstalledPluginVersion(targetPluginDir); diff --git a/src/prerelease-workflow.test.ts b/src/prerelease-workflow.test.ts index 08c68086..ba1c819b 100644 --- a/src/prerelease-workflow.test.ts +++ b/src/prerelease-workflow.test.ts @@ -2,9 +2,17 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { + jobSteps, + readWorkflow, + stepRunsCommand, + stepsMissingEnvDeclaration, + templateExpressionsInRunBodies, +} from './workflow-test-helpers'; const prereleaseWorkflowPath = resolve(__dirname, '../.github/workflows/prerelease.yml'); const prereleaseWorkflow = readFileSync(prereleaseWorkflowPath, 'utf8').replace(/\r\n/g, '\n'); +const parsedPrereleaseWorkflow = readWorkflow(prereleaseWorkflowPath); const packageJsonPath = resolve(__dirname, '../package.json'); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { scripts: Record; @@ -122,3 +130,33 @@ test('prerelease workflow does not publish to AUR', () => { assert.doesNotMatch(prereleaseWorkflow, /AUR_SSH_PRIVATE_KEY/); assert.doesNotMatch(prereleaseWorkflow, /scripts\/update-aur-package\.sh/); }); + +test('prerelease workflow rejects committed notes generated for a different beta or rc', () => { + assert.equal( + packageJson.scripts['changelog:check-prerelease-notes'], + 'bun run scripts/build-changelog.ts check-prerelease-notes', + ); + + // Matched at command positions only, so commenting the check out or quoting it + // inside an echo fails the test rather than silently satisfying it. + const steps = jobSteps(parsedPrereleaseWorkflow, 'release'); + const checkIndex = steps.findIndex((step) => + stepRunsCommand( + step, + /^bun run changelog:check-prerelease-notes --version "\$RELEASE_VERSION"/, + ), + ); + const publishIndex = steps.findIndex((step) => + stepRunsCommand(step, /^gh release (create|edit)\b/), + ); + + assert.notEqual(checkIndex, -1); + assert.notEqual(publishIndex, -1); + // Stale notes are already published if the check runs after the release. + assert.ok(checkIndex < publishIndex); +}); + +test('prerelease workflow keeps tag-derived values out of shell bodies', () => { + assert.deepEqual(templateExpressionsInRunBodies(parsedPrereleaseWorkflow), []); + assert.deepEqual(stepsMissingEnvDeclaration(parsedPrereleaseWorkflow, 'RELEASE_VERSION'), []); +}); diff --git a/src/release-workflow.test.ts b/src/release-workflow.test.ts index 0cfda411..e3cc3c7d 100644 --- a/src/release-workflow.test.ts +++ b/src/release-workflow.test.ts @@ -2,11 +2,18 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { + readWorkflow, + stepsMissingEnvDeclaration, + templateExpressionsInRunBodies, +} from './workflow-test-helpers'; const releaseWorkflowPath = resolve(__dirname, '../.github/workflows/release.yml'); const releaseWorkflow = readFileSync(releaseWorkflowPath, 'utf8'); const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml'); const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8'); +const parsedReleaseWorkflow = readWorkflow(releaseWorkflowPath); +const parsedDocsPagesWorkflow = readWorkflow(docsPagesWorkflowPath); const makefilePath = resolve(__dirname, '../Makefile'); const makefile = readFileSync(makefilePath, 'utf8'); const packageJsonPath = resolve(__dirname, '../package.json'); @@ -249,7 +256,7 @@ test('release workflow publishes subminer-bin to AUR from tagged release artifac releaseWorkflow, /cp packaging\/aur\/subminer-bin\/\.SRCINFO aur-subminer-bin\/\.SRCINFO/, ); - assert.match(releaseWorkflow, /version_no_v="\$\{\{ steps\.version\.outputs\.VERSION \}\}"/); + assert.match(releaseWorkflow, /version_no_v="\$RELEASE_VERSION"/); assert.match(releaseWorkflow, /SubMiner-\$\{version_no_v\}\.AppImage/); assert.doesNotMatch( releaseWorkflow, @@ -278,3 +285,14 @@ test('Makefile uninstall targets remove bundled runtime plugin app-data copies', assert.match(makefile, /Removed:[\s\S]*\$\(LINUX_DATA_DIR\)\/plugin\/subminer/); assert.match(makefile, /Removed:[\s\S]*\$\(MACOS_DATA_DIR\)\/plugin\/subminer/); }); + +test('release and docs workflows keep tag-derived values out of shell bodies', () => { + assert.deepEqual(templateExpressionsInRunBodies(parsedReleaseWorkflow), []); + assert.deepEqual(templateExpressionsInRunBodies(parsedDocsPagesWorkflow), []); + assert.deepEqual(stepsMissingEnvDeclaration(parsedReleaseWorkflow, 'RELEASE_VERSION'), []); + assert.deepEqual(stepsMissingEnvDeclaration(parsedDocsPagesWorkflow, 'TAG_NAME'), []); + + // The docs tag guard must test the shell variable, not an interpolated value + // that would be substituted into the condition before the shell reads it. + assert.match(docsPagesWorkflow, /if \[\[ ! "\$TAG_NAME" =~/); +}); diff --git a/src/renderer/modals/subtitle-sidebar.test.ts b/src/renderer/modals/subtitle-sidebar.test.ts index f9a46b35..e369f88a 100644 --- a/src/renderer/modals/subtitle-sidebar.test.ts +++ b/src/renderer/modals/subtitle-sidebar.test.ts @@ -240,14 +240,15 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback const snapshot: SubtitleSidebarSnapshot = { cues: [ - { startTime: 1, endTime: 2, text: 'first' }, + { startTime: 1, endTime: 3.4, text: 'first' }, { startTime: 3, endTime: 4, text: 'second' }, ], currentSubtitle: { text: 'second', - startTime: 3, + startTime: 3.5, endTime: 4, }, + currentTimeSec: 3.5, config: { enabled: true, autoOpen: false, @@ -361,6 +362,9 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback modal.seekToCue(snapshot.cues[0]!); assert.deepEqual(mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']); + modal.seekToCue(snapshot.cues[1]!); + assert.deepEqual(mpvCommands.at(-1), ['seek', 3.48, 'absolute+exact']); + modal.closeSubtitleSidebarModal(); assert.deepEqual(visibilityChanges, [true, false]); assert.deepEqual(modalNotifications, ['open:subtitle-sidebar', 'close:subtitle-sidebar']); diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index 4b3364cb..8d5f797f 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -4,6 +4,7 @@ import type { SubtitleMiningContext, SubtitleSidebarSnapshot, } from '../../types'; +import { subtitleCueListSeekTime } from '../../core/services/subtitle-cue-navigation.js'; import type { ModalStateReader, RendererContext } from '../context'; import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js'; import { @@ -14,7 +15,6 @@ import { const MANUAL_SCROLL_HOLD_MS = 1500; const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18; -const CLICK_SEEK_OFFSET_SEC = 0.08; const SNAPSHOT_POLL_INTERVAL_MS = 80; const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240; const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45; @@ -392,10 +392,9 @@ export function createSubtitleSidebarModal( } function seekToCue(cue: SubtitleCue): void { - const targetTime = Math.min(cue.endTime - 0.01, cue.startTime + CLICK_SEEK_OFFSET_SEC); window.electronAPI.sendMpvCommand([ 'seek', - Math.max(cue.startTime, targetTime), + subtitleCueListSeekTime(ctx.state.subtitleSidebarCues, cue), 'absolute+exact', ]); } diff --git a/src/renderer/style.css b/src/renderer/style.css index f1aa7704..e4977cdc 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -1928,10 +1928,6 @@ body.layer-modal #overlay { text-align: center; font-size: 24px; line-height: 1.5; - /* Backstop: pathological tracks (karaoke typesetting, sign spam) must never grow - the hover-pause band beyond a top strip. ~4 lines at line-height 1.5. */ - max-height: 6em; - overflow: hidden; color: #ffffff; -webkit-text-stroke: 0.45px rgba(0, 0, 0, 0.7); paint-order: stroke fill; diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index d8ab072a..ca297908 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -11,6 +11,7 @@ import { getFrequencyRankLabelForToken, getJlptLevelLabelForToken, normalizeSubtitle, + normalizeSubtitleForDisplay, prepareSecondarySubtitleLines, sanitizeSubtitleHoverTokenColor, shouldRenderTokenizedSubtitle, @@ -1004,6 +1005,34 @@ test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks i ); }); +test('normalizeSubtitleForDisplay always breaks between simultaneous cues', () => { + // The blank line marks two distinct cues on screen at once. Flattening it would run a + // sign or a second speaker into the line beside it as one sentence. + const twoCues = + '\u6b21\u306f\u9b3c\u5b50\u6bcd\u795e\u524d\u3000\u9b3c\u5b50\u6bcd\u795e\u524d\n\n\u611b\u97f3\u3061\u3083\u3093\u3000\u3082\u3046\u5199\u771f\u4e0a\u3052\u3066\u308b'; + + assert.equal( + normalizeSubtitleForDisplay(twoCues, false), + '\u6b21\u306f\u9b3c\u5b50\u6bcd\u795e\u524d \u9b3c\u5b50\u6bcd\u795e\u524d\n\u611b\u97f3\u3061\u3083\u3093 \u3082\u3046\u5199\u771f\u4e0a\u3052\u3066\u308b', + ); + assert.equal(normalizeSubtitleForDisplay(twoCues, true), twoCues.replace('\n\n', '\n')); +}); + +test('normalizeSubtitleForDisplay preserves CRLF boundaries between simultaneous cues', () => { + assert.equal(normalizeSubtitleForDisplay('a\r\n\r\nb', false), 'a\nb'); +}); + +test('normalizeSubtitleForDisplay still flattens a wrap inside one cue', () => { + // A typesetter's \\N inside a single utterance is what preserveLineBreaks governs. + assert.equal( + normalizeSubtitleForDisplay( + '\u5e38\u4eba\u304c\u4f7f\u3048\u3070\\N\u305d\u306e\u5727\u5012\u7684\u306a\u529b\u306b', + false, + ), + '\u5e38\u4eba\u304c\u4f7f\u3048\u3070 \u305d\u306e\u5727\u5012\u7684\u306a\u529b\u306b', + ); +}); + test('normalizeSubtitle leaves already-decoded text alone', () => { // Primary subtitle text is decoded from ASS once, upstream: by mpv for live lines and // by the cue parser for prefetched ones. A brace that survives that is literal text. @@ -1424,6 +1453,26 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo ); }); +test('prepareSecondarySubtitleLines collapses exact short copies in stacks', () => { + assert.deepEqual(prepareSecondarySubtitleLines('Your\\NYour\\NYour\\NYour\\Nmosaic'), [ + 'Your', + 'mosaic', + ]); + assert.deepEqual(prepareSecondarySubtitleLines('One line\\NAnother line'), [ + 'One line', + 'Another line', + ]); +}); + +test('prepareSecondarySubtitleLines collapses exact short sign copies beside dialogue', () => { + const liveText = "And for today's sports festival...\nEntrance\nEntrance"; + + assert.deepEqual(prepareSecondarySubtitleLines(liveText), [ + "And for today's sports festival...", + 'Entrance', + ]); +}); + test('prepareSecondarySubtitleLines collapses karaoke syllable spam into one deduped line', () => { // Karaoke-typeset OP/ED: one ASS event per syllable, duplicated across layers, // joined with \N by mpv's secondary-sub-text. @@ -1434,6 +1483,31 @@ test('prepareSecondarySubtitleLines collapses karaoke syllable spam into one ded assert.deepEqual(prepareSecondarySubtitleLines(karaoke), ['ya This no ma ups']); }); +test('prepareSecondarySubtitleLines collapses exact repeated short lines', () => { + const dialogue = ['Wait', 'Wait', 'Wait']; + + assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), ['Wait']); +}); + +test('prepareSecondarySubtitleLines collapses punctuation variants of a full-sentence fallback', () => { + const dialogue = 'A question veiled as an insult!'; + const positionedSign = 'A question veiled as an insult'; + + assert.deepEqual(prepareSecondarySubtitleLines([dialogue, positionedSign].join('\\N')), [ + dialogue, + ]); +}); + +test('prepareSecondarySubtitleLines preserves short simultaneous dialogue without repeats', () => { + const dialogue = ['Wait', 'Go!', 'No!', 'Run!']; + + assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), dialogue); +}); + +test('prepareSecondarySubtitleLines preserves distinct short lines with internal whitespace', () => { + assert.deepEqual(prepareSecondarySubtitleLines('AB\\NA B'), ['AB', 'A B']); +}); + test('prepareSecondarySubtitleLines keeps normal dialogue lines intact', () => { const dialogue = ' I never expected this. \\N\\N But here we are. '; @@ -1455,13 +1529,13 @@ test('prepareSecondarySubtitleLines strips ASS override tags and handles empty i assert.deepEqual(prepareSecondarySubtitleLines('{\\an8}'), []); }); -test('secondary subtitle root CSS caps height so hover-pause band stays a top strip', () => { +test('secondary subtitle root CSS does not clip long subtitle stacks', () => { const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css'); const cssText = fs.readFileSync(srcCssPath, 'utf-8'); const secondaryRootBlock = extractClassBlock(cssText, '#secondarySubRoot'); - assert.match(secondaryRootBlock, /max-height:\s*6em;/); - assert.match(secondaryRootBlock, /overflow:\s*hidden;/); + assert.doesNotMatch(secondaryRootBlock, /max-height\s*:/); + assert.doesNotMatch(secondaryRootBlock, /overflow\s*:\s*hidden/); }); test('applySubtitleStyle sets known-word maturity color variables', () => { diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index 8b63c004..bccfc829 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -6,6 +6,7 @@ import type { SubtitleRendererStyleConfig, } from '../types'; import { assToPlainText, normalizePlainSubtitleText } from '../core/services/ass-text.js'; +import { flattenedSecondarySubtitleLineIdentity } from '../core/services/secondary-subtitle-line-identity.js'; import type { RendererContext } from './context'; import { PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS } from './yomitan-popup.js'; @@ -49,6 +50,21 @@ export function normalizeSubtitle(text: string, trim = true, collapseLineBreaks return normalizePlainSubtitleText(text, { trim, collapseLineBreaks }); } +/** + * Display form of a resolved subtitle. `preserveLineBreaks` governs wrapping inside one + * utterance, which is what a typesetter's `\N` means. The blank line the resolver puts + * between two simultaneous cues is a different thing and always breaks, so a sign or a + * second speaker never runs into the line beside it. + */ +export function normalizeSubtitleForDisplay(text: string, preserveLineBreaks: boolean): string { + return text + .replace(/\r\n/g, '\n') + .split(/\n{2,}/) + .map((cueText) => normalizeSubtitle(cueText, true, !preserveLineBreaks)) + .filter((cueText) => cueText.length > 0) + .join('\n'); +} + const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; const SAFE_CSS_COLOR_PATTERN = /^(?:#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgba?|hsla?)\([^)]*\)|var\([^)]*\)|[a-zA-Z]+)$/; @@ -413,16 +429,13 @@ function renderWithTokens( const fragment = document.createDocumentFragment(); if (sourceText) { - const normalizedSource = normalizeSubtitle(sourceText, true, !preserveLineBreaks); + const normalizedSource = normalizeSubtitleForDisplay(sourceText, preserveLineBreaks); const segments = alignTokensToSourceText(tokens, normalizedSource); for (const segment of segments) { if (segment.kind === 'text') { - if (preserveLineBreaks) { - renderPlainTextPreserveLineBreaks(fragment, segment.text); - } else { - fragment.appendChild(document.createTextNode(segment.text)); - } + // Normalization already resolved which breaks survive; every one left is real. + renderPlainTextPreserveLineBreaks(fragment, segment.text); continue; } @@ -665,6 +678,22 @@ function isKaraokeLikeLineSet(lines: string[]): boolean { return median <= KARAOKE_MAX_MEDIAN_LINE_LENGTH; } +function collapseFullLineFallbackCopies(lines: string[]): string[] { + const seenExact = new Set(); + const seenFlattened = new Set(); + return lines.filter((line) => { + const exactIdentity = line.normalize('NFKC'); + if (seenExact.has(exactIdentity)) return false; + seenExact.add(exactIdentity); + + const flattenedIdentity = flattenedSecondarySubtitleLineIdentity(line); + if (!flattenedIdentity) return true; + if (seenFlattened.has(flattenedIdentity)) return false; + seenFlattened.add(flattenedIdentity); + return true; + }); +} + export function prepareSecondarySubtitleLines(text: string): string[] { // The one display-side ASS decode: secondary text also reaches the overlay from // websocket clients that forward their source line untouched, so unlike the primary @@ -678,7 +707,7 @@ export function prepareSecondarySubtitleLines(text: string): string[] { .map((line) => line.trim()) .filter((line) => line.length > 0); if (!isKaraokeLikeLineSet(lines)) { - return lines; + return collapseFullLineFallbackCopies(lines); } const seen = new Set(); @@ -731,7 +760,7 @@ export function createSubtitleRenderer(ctx: RendererContext) { return; } - const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks); + const normalized = normalizeSubtitleForDisplay(text, ctx.state.preserveSubtitleLineBreaks); const hasRenderableTokens = shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && Boolean(tokens); if ( diff --git a/src/types/stats-http-contract.ts b/src/types/stats-http-contract.ts index 874e9b76..a920761a 100644 --- a/src/types/stats-http-contract.ts +++ b/src/types/stats-http-contract.ts @@ -50,6 +50,24 @@ export interface StatsKnownWordsSummary { knownWordCount: number; } +export interface StatsVocabularySummary { + uniqueWords: number; + uniqueWordsWithoutNames: number; + uniqueKanji: number; + newThisWeek: number; + newThisWeekWithoutNames: number; + knownWordCount: number | null; + knownWordCountWithoutNames: number | null; +} + +export interface StatsVocabularyCharts { + ready: boolean; + topWords: Array<{ wordId: number; headword: string; frequency: number }>; + topWordsWithoutNames: Array<{ wordId: number; headword: string; frequency: number }>; + newWordsTimeline: Array<{ epochDay: number; wordCount: number }>; + newWordsTimelineWithoutNames: Array<{ epochDay: number; wordCount: number }>; +} + export interface StatsAnilistSearchResult { id: number; episodes: number | null; @@ -164,6 +182,8 @@ export interface StatsJsonResponseMap { sessionEvents: SessionEvent[]; sessionKnownWordsTimeline: StatsSessionKnownWordsTimelinePoint[]; vocabulary: VocabularyEntry[]; + vocabularySummary: StatsVocabularySummary; + vocabularyCharts: StatsVocabularyCharts; excludedWords: StatsExcludedWord[]; setExcludedWords: StatsOkResponse; duplicateLineCleanup: StatsDuplicateLineCleanupResult; @@ -222,6 +242,8 @@ export interface StatsHttpClient { getSessionEvents: (id: number, limit?: number, eventTypes?: number[]) => Promise; getSessionKnownWordsTimeline: (id: number) => Promise; getVocabulary: (limit?: number) => Promise; + getVocabularySummary: () => Promise; + getVocabularyCharts: () => Promise; getExcludedWords: () => Promise; setExcludedWords: (words: StatsExcludedWord[]) => Promise; cleanupDuplicateLines: ( diff --git a/src/types/subtitle.ts b/src/types/subtitle.ts index 409d0e2e..03c63683 100644 --- a/src/types/subtitle.ts +++ b/src/types/subtitle.ts @@ -1,4 +1,4 @@ -import type { SubtitleCue } from '../core/services/subtitle-cue-parser'; +import type { AssVerticalBand, SubtitleCue } from '../core/services/subtitle-cue-parser'; export enum PartOfSpeech { noun = 'noun', @@ -187,7 +187,7 @@ export interface ResolvedTokenPos2ExclusionConfig { export type FrequencyDictionaryMode = 'single' | 'banded'; -export type { SubtitleCue }; +export type { AssVerticalBand, SubtitleCue }; export type SubtitleSidebarLayout = 'overlay' | 'embedded'; diff --git a/src/workflow-test-helpers.test.ts b/src/workflow-test-helpers.test.ts new file mode 100644 index 00000000..c4190b8d --- /dev/null +++ b/src/workflow-test-helpers.test.ts @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + commandPositions, + executableRunLines, + stepRunsCommand, + stepsMissingEnvDeclaration, + templateExpressionsInRunBodies, +} from './workflow-test-helpers'; + +const runs = (run: string): boolean => stepRunsCommand({ run }, /^bun run verify --flag "\$VALUE"/); + +test('stepRunsCommand matches a command that actually executes', () => { + assert.equal(runs('bun run verify --flag "$VALUE"'), true); + assert.equal(runs('if ! bun run verify --flag "$VALUE"; then\nexit 1\nfi'), true); + assert.equal(runs('set -e && bun run verify --flag "$VALUE"'), true); + assert.equal(runs(' bun run verify --flag "$VALUE" || exit 1'), true); +}); + +test('stepRunsCommand rejects commands that are only mentioned, not run', () => { + assert.equal(runs('# bun run verify --flag "$VALUE"'), false); + assert.equal(runs('echo \'bun run verify --flag "$VALUE"\''), false); + assert.equal(runs("printf '%s\\n' 'bun run verify --flag \"$VALUE\"'"), false); + assert.equal(runs('echo "run: bun run verify --flag \\"$VALUE\\"" >> notes.txt'), false); + // A different argument list is a different command. + assert.equal(runs('bun run verify'), false); +}); + +test('stepRunsCommand ignores separators inside quotes and inline comments', () => { + assert.equal(runs('echo \'note; bun run verify --flag "$VALUE"\''), false); + assert.equal(runs('echo "note && bun run verify --flag \\"$VALUE\\""'), false); + assert.equal(runs("printf '%s\\n' 'a | bun run verify --flag \"$VALUE\"'"), false); + assert.equal(runs('if false; then # bun run verify --flag "$VALUE"'), false); + // A trailing comment does not hide the command in front of it. + assert.equal(runs('bun run verify --flag "$VALUE" # keep this'), true); + // A pipe is a real separator; a redirect is not. + assert.equal(runs('cat notes | bun run verify --flag "$VALUE"'), true); + assert.equal(stepRunsCommand({ run: 'gh release view "$V" 2>&1 | tee log' }, /^tee\b/), true); +}); + +test('stepRunsCommand treats backslash-escaped separators as literal text', () => { + assert.equal(runs(String.raw`echo foo \; bun run verify --flag "$VALUE"`), false); + assert.equal(runs(String.raw`echo foo \| bun run verify --flag "$VALUE"`), false); + assert.equal(runs(String.raw`find . -exec bun run verify --flag "$VALUE" \;`), false); + // An escape does not swallow a following real separator. + assert.equal(runs(String.raw`echo a\b; bun run verify --flag "$VALUE"`), true); +}); + +test('commandPositions splits on separators and strips control-flow prefixes', () => { + assert.deepEqual( + commandPositions({ run: 'if gh release view "$V"; then\ngh release edit "$V"\nfi' }), + ['gh release view "$V"', 'then', 'gh release edit "$V"', 'fi'], + ); +}); + +test('executableRunLines drops blank and comment-only lines', () => { + assert.deepEqual(executableRunLines({ run: '\n# a comment\n \nreal command\n' }), [ + 'real command', + ]); +}); + +test('templateExpressionsInRunBodies reports every expression spelling in a run body', () => { + const workflow = { + jobs: { + release: { + steps: [ + { name: 'Safe', env: { V: '${{ steps.version.outputs.VERSION }}' }, run: 'echo "$V"' }, + { name: 'Dotted', run: 'echo "${{ steps.version.outputs.VERSION }}"' }, + { name: 'Bracketed', run: 'echo "${{ steps.version.outputs[\'VERSION\'] }}"' }, + { name: 'Github', run: 'echo "${{ github[\'ref_name\'] }}"' }, + ], + }, + }, + }; + + assert.deepEqual(templateExpressionsInRunBodies(workflow), [ + 'release/Dotted: ${{ steps.version.outputs.VERSION }}', + "release/Bracketed: ${{ steps.version.outputs['VERSION'] }}", + "release/Github: ${{ github['ref_name'] }}", + ]); +}); + +test('stepsMissingEnvDeclaration finds shell reads with no matching env entry', () => { + const workflow = { + jobs: { + release: { + steps: [ + { name: 'Declared', env: { TAG: 'x' }, run: 'echo "$TAG"' }, + { name: 'Undeclared', run: 'echo "${TAG}"' }, + { name: 'Unrelated', run: 'echo "$TAGGED"' }, + ], + }, + }, + }; + + assert.deepEqual(stepsMissingEnvDeclaration(workflow, 'TAG'), ['release/Undeclared']); +}); diff --git a/src/workflow-test-helpers.ts b/src/workflow-test-helpers.ts new file mode 100644 index 00000000..9e74fae2 --- /dev/null +++ b/src/workflow-test-helpers.ts @@ -0,0 +1,169 @@ +import { readFileSync } from 'node:fs'; + +export type WorkflowStep = { + name?: string; + run?: string; + env?: Record; +}; + +export type ParsedWorkflow = { + jobs?: Record; +}; + +// Workflow tests only ever run under `bun test`, which parses YAML natively. +function parseWorkflowYaml(source: string): ParsedWorkflow { + const bunRuntime = globalThis as typeof globalThis & { + Bun?: { YAML?: { parse?: (input: string) => unknown } }; + }; + const parse = bunRuntime.Bun?.YAML?.parse; + if (!parse) { + throw new Error('Bun.YAML.parse is unavailable; workflow tests must run under bun.'); + } + return parse(source) as ParsedWorkflow; +} + +export function readWorkflow(workflowPath: string): ParsedWorkflow { + return parseWorkflowYaml(readFileSync(workflowPath, 'utf8')); +} + +// Steps of one job, in declaration order. Throws on an unknown job so a renamed +// job fails loudly instead of silently emptying an ordering assertion. +export function jobSteps(workflow: ParsedWorkflow, jobName: string): WorkflowStep[] { + const job = workflow.jobs?.[jobName]; + if (!job) { + throw new Error(`Workflow has no job named ${jobName}.`); + } + return job.steps ?? []; +} + +function allSteps(workflow: ParsedWorkflow): Array<{ job: string; step: WorkflowStep }> { + return Object.entries(workflow.jobs ?? {}).flatMap(([job, definition]) => + (definition?.steps ?? []).map((step) => ({ job, step })), + ); +} + +// Lines of a step's shell body that actually execute. Comments are dropped so a +// commented-out command cannot satisfy a "this step runs X" assertion. +export function executableRunLines(step: WorkflowStep): string[] { + return (typeof step.run === 'string' ? step.run.split('\n') : []) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')); +} + +// Leading shell keywords and operators that can precede a real command. +const COMMAND_PREFIX = /^(?:if|elif|while|until|then|else|do|!|&&|\|\||\(|\{)\s+/; + +// Splits one shell line on command separators, tracking quotes so a separator +// inside a string is not treated as a command break, and stopping at an +// unquoted inline comment. +function splitCommandSeparators(line: string): string[] { + const segments: string[] = []; + let current = ''; + let quote: "'" | '"' | null = null; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]!; + + if (quote) { + current += char; + if (char === '\\' && quote === '"' && index + 1 < line.length) { + current += line[index + 1]!; + index += 1; + } else if (char === quote) { + quote = null; + } + continue; + } + + // An unquoted backslash escapes the next character, so `\;` is literal text + // rather than a separator. Checked before comments and separators. + if (char === '\\' && index + 1 < line.length) { + current += char + line[index + 1]!; + index += 1; + continue; + } + + if (char === "'" || char === '"') { + quote = char; + current += char; + continue; + } + + // An unquoted # starts a comment when it opens a word; the rest is inert. + if (char === '#' && (current === '' || /\s$/.test(current))) { + break; + } + + const next = line[index + 1]; + if (char === ';') { + segments.push(current); + current = ''; + continue; + } + if ((char === '&' || char === '|') && next === char) { + segments.push(current); + current = ''; + index += 1; + continue; + } + // A lone pipe separates commands; a redirect such as 2>&1 does not. + if (char === '|' && !/[0-9<>&]$/.test(current)) { + segments.push(current); + current = ''; + continue; + } + + current += char; + } + + segments.push(current); + return segments; +} + +// Command positions within a step's shell body: each line split on separators, +// with control-flow prefixes stripped. A pattern anchored with ^ therefore +// matches only where a command actually starts, so text quoted inside an +// `echo`/`printf` argument is not mistaken for the command running. +export function commandPositions(step: WorkflowStep): string[] { + return executableRunLines(step).flatMap((line) => + splitCommandSeparators(line) + .map((segment) => { + let candidate = segment.trim(); + let stripped = candidate.replace(COMMAND_PREFIX, ''); + while (stripped !== candidate) { + candidate = stripped; + stripped = candidate.replace(COMMAND_PREFIX, ''); + } + return candidate; + }) + .filter(Boolean), + ); +} + +// Whether a step actually executes a command matching the pattern. Anchor the +// pattern with ^ so it has to match at a command position. +export function stepRunsCommand(step: WorkflowStep, pattern: RegExp): boolean { + return commandPositions(step).some((position) => pattern.test(position)); +} + +// GitHub substitutes ${{ }} into a run script before the shell parses it, so any +// value used that way is executed as script rather than read as data. Reporting +// every expression (rather than allow-listing known-safe ones) also covers +// alternate spellings such as ${{ steps.version.outputs['VERSION'] }}. +export function templateExpressionsInRunBodies(workflow: ParsedWorkflow): string[] { + return allSteps(workflow).flatMap(({ job, step }) => + (typeof step.run === 'string' ? (step.run.match(/\$\{\{[\s\S]*?\}\}/g) ?? []) : []).map( + (expression) => `${job}/${step.name ?? ''}: ${expression}`, + ), + ); +} + +// Steps whose shell body reads $NAME without the step declaring it in env, which +// would silently expand to an empty string at run time. +export function stepsMissingEnvDeclaration(workflow: ParsedWorkflow, name: string): string[] { + const reference = new RegExp(`\\$${name}\\b|\\$\\{${name}\\b`); + return allSteps(workflow) + .filter(({ step }) => typeof step.run === 'string' && reference.test(step.run)) + .filter(({ step }) => !Object.prototype.hasOwnProperty.call(step.env ?? {}, name)) + .map(({ job, step }) => `${job}/${step.name ?? ''}`); +} diff --git a/stats/src/components/vocabulary/VocabularyTab.tsx b/stats/src/components/vocabulary/VocabularyTab.tsx index 70dbf2f2..0eefcf06 100644 --- a/stats/src/components/vocabulary/VocabularyTab.tsx +++ b/stats/src/components/vocabulary/VocabularyTab.tsx @@ -6,11 +6,10 @@ import { KanjiBreakdown } from './KanjiBreakdown'; import { KanjiDetailPanel } from './KanjiDetailPanel'; import { ExclusionManager } from './ExclusionManager'; import { DuplicateLineCleanup } from './DuplicateLineCleanup'; -import { formatNumber } from '../../lib/formatters'; +import { epochDayToDate, formatNumber } from '../../lib/formatters'; import { TrendChart } from '../trends/TrendChart'; import { FrequencyRankTable } from './FrequencyRankTable'; import { CrossAnimeWordsTable } from './CrossAnimeWordsTable'; -import { buildVocabularySummary } from '../../lib/dashboard-data'; import type { ExcludedWord } from '../../hooks/useExcludedWords'; import type { KanjiEntry, VocabularyEntry } from '../../types/stats'; @@ -35,7 +34,18 @@ export function VocabularyTab({ onRemoveExclusion, onClearExclusions, }: VocabularyTabProps) { - const { words, kanji, knownWords, loading, error, reload } = useVocabulary(); + const { + words, + kanji, + knownWords, + summary, + charts, + loading, + error, + aggregatesError, + refreshAggregates, + reload, + } = useVocabulary(); const [selectedKanjiId, setSelectedKanjiId] = useState(null); const [hideNames, setHideNames] = useState(false); const [showExclusionManager, setShowExclusionManager] = useState(false); @@ -48,19 +58,26 @@ export function VocabularyTab({ if (excluded.length > 0) result = result.filter((w) => !isExcluded(w)); return result; }, [words, hideNames, excluded, isExcluded]); - const summary = useMemo( - () => buildVocabularySummary(filteredWords, kanji), - [filteredWords, kanji], + const chartData = useMemo( + () => ({ + topWords: ((hideNames ? charts?.topWordsWithoutNames : charts?.topWords) ?? []).map( + (word) => ({ + label: word.headword, + value: word.frequency, + }), + ), + newWordsTimeline: ( + (hideNames ? charts?.newWordsTimelineWithoutNames : charts?.newWordsTimeline) ?? [] + ).map((point) => ({ + label: epochDayToDate(point.epochDay).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }), + value: point.wordCount, + })), + }), + [charts, hideNames], ); - const knownWordCount = useMemo(() => { - if (knownWords.size === 0) return 0; - - let count = 0; - for (const w of filteredWords) { - if (knownWords.has(w.headword)) count += 1; - } - return count; - }, [filteredWords, knownWords]); if (loading) { return ( @@ -82,7 +99,9 @@ export function VocabularyTab({ }; const handleBarClick = (headword: string): void => { - const match = filteredWords.find((w) => w.headword === headword); + const match = (hideNames ? charts?.topWordsWithoutNames : charts?.topWords)?.find( + (word) => word.headword === headword, + ); if (match) onOpenWordDetail?.(match.wordId); }; @@ -90,33 +109,60 @@ export function VocabularyTab({ setSelectedKanjiId(entry.kanjiId); }; + const displayedSummary = hideNames + ? { + uniqueWords: summary?.uniqueWordsWithoutNames ?? 0, + newThisWeek: summary?.newThisWeekWithoutNames ?? 0, + knownWordCount: summary?.knownWordCountWithoutNames ?? null, + } + : { + uniqueWords: summary?.uniqueWords ?? 0, + newThisWeek: summary?.newThisWeek ?? 0, + knownWordCount: summary?.knownWordCount ?? null, + }; + return (

- {knownWords.size > 0 && ( + {displayedSummary.knownWordCount !== null ? ( 0 ? Math.round((knownWordCount / summary.uniqueWords) * 100) : 0}%)`} + value={`${formatNumber(displayedSummary.knownWordCount)} (${displayedSummary.uniqueWords > 0 ? Math.round((displayedSummary.knownWordCount / displayedSummary.uniqueWords) * 100) : 0}%)`} color="text-ctp-green" /> - )} + ) : knownWords.size > 0 ? ( + + ) : null}
+ {aggregatesError && ( +

+ {aggregatesError}{' '} + +

+ )} +
{hasNames && (
+ {charts && !charts.ready && ( +

+ Building vocabulary history in the background… +

+ )} + { + resetExcludedWordsStoreForTests(); + const { values: storage, restore } = installLocalStorage(); + const originalFetch = globalThis.fetch; + const originalConsoleError = console.error; + console.error = () => {}; + globalThis.fetch = (async () => + new Response(JSON.stringify({ ok: true }), { status: 200 })) as typeof globalThis.fetch; + const notified: string[] = []; + const unsubscribeFirst = subscribeExcludedWordsServerSync(() => { + notified.push('first'); + throw new Error('listener exploded'); + }); + const unsubscribeSecond = subscribeExcludedWordsServerSync(() => { + notified.push('second'); + }); + + try { + const rows = [{ headword: 'する', word: 'する', reading: 'する' }]; + await assert.doesNotReject(() => setExcludedWords(rows)); + + assert.deepEqual(notified, ['first', 'second']); + assert.deepEqual(getExcludedWordsSnapshot(), rows); + assert.equal(storage.get(STORAGE_KEY), JSON.stringify(rows)); + } finally { + unsubscribeFirst(); + unsubscribeSecond(); + globalThis.fetch = originalFetch; + console.error = originalConsoleError; + restore(); + resetExcludedWordsStoreForTests(); + } +}); + +test('overlapping writes serialize so an older list cannot overwrite a newer edit', async () => { + resetExcludedWordsStoreForTests(); + const { restore } = installLocalStorage(); + const originalFetch = globalThis.fetch; + const sentBodies: string[] = []; + let releaseFirst: (() => void) | null = null; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + sentBodies.push(String(init?.body ?? '')); + if (sentBodies.length === 1) { + await new Promise((resolve) => { + releaseFirst = resolve; + }); + } + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }) as typeof globalThis.fetch; + const syncs: string[] = []; + const unsubscribe = subscribeExcludedWordsServerSync(() => { + syncs.push(JSON.stringify(getExcludedWordsSnapshot())); + }); + + try { + const first = [{ headword: '猫', word: '猫', reading: 'ねこ' }]; + const second = [...first, { headword: '犬', word: '犬', reading: 'いぬ' }]; + const third = [...second, { headword: '鳥', word: '鳥', reading: 'とり' }]; + + // The first write reaches the network before the later edits are made. + const firstWrite = setExcludedWords(first); + await new Promise((resolve) => setTimeout(resolve, 0)); + const secondWrite = setExcludedWords(second); + const thirdWrite = setExcludedWords(third); + + const release = releaseFirst as (() => void) | null; + assert.ok(release, 'expected the first write to be in flight'); + release(); + await Promise.all([firstWrite, secondWrite, thirdWrite]); + + // The in-flight write finishes first, the superseded middle write is + // dropped, and the newest list is the last thing the server is told. + assert.deepEqual(sentBodies, [ + JSON.stringify({ words: first }), + JSON.stringify({ words: third }), + ]); + assert.deepEqual(getExcludedWordsSnapshot(), third); + // Only the final revision notifies: the first write's acknowledgement was + // already obsolete, so it must not trigger an aggregate recomputation. + assert.deepEqual(syncs, [JSON.stringify(third)]); + } finally { + unsubscribe(); + globalThis.fetch = originalFetch; + restore(); + resetExcludedWordsStoreForTests(); + } +}); diff --git a/stats/src/hooks/useExcludedWords.ts b/stats/src/hooks/useExcludedWords.ts index 962f6592..814ae0da 100644 --- a/stats/src/hooks/useExcludedWords.ts +++ b/stats/src/hooks/useExcludedWords.ts @@ -44,6 +44,32 @@ let cachedKeys: Set | null = null; let initialized: Promise | null = null; let revision = 0; const listeners = new Set<() => void>(); +// Fires only after the stats server acknowledged an exclusion write, so +// subscribers can refetch server-computed aggregates without racing the POST. +const serverSyncListeners = new Set<() => void>(); + +export function subscribeExcludedWordsServerSync(fn: () => void): () => void { + serverSyncListeners.add(fn); + return () => { + serverSyncListeners.delete(fn); + }; +} + +function notifyServerSync(): void { + // Listener failures are their own concern: one must not roll back a write + // that already succeeded, nor stop the remaining listeners from running. + for (const fn of serverSyncListeners) { + try { + fn(); + } catch (error) { + console.error('Excluded words server-sync listener failed', error); + } + } +} + +// Full-list writes are serialized so a slow earlier request cannot land after a +// newer one and overwrite it with a stale list. +let writeChain: Promise = Promise.resolve(); function readLocalStorage(): ExcludedWord[] { if (typeof localStorage === 'undefined') return []; @@ -102,16 +128,27 @@ export async function setExcludedWords(words: ExcludedWord[]): Promise { const normalized = dedupeExcludedWords(words); revision = writeRevision; applyWords(normalized); - try { - await apiClient.setExcludedWords(normalized); - } catch (error) { - if (revision === writeRevision) { - revision = previousRevision; - applyWords(previousWords); + const write = writeChain.then(async () => { + // A newer edit already superseded this list and carries the newest state, + // so sending this one would push a stale list to the server. + if (revision !== writeRevision) return; + try { + await apiClient.setExcludedWords(normalized); + } catch (error) { + if (revision === writeRevision) { + revision = previousRevision; + applyWords(previousWords); + } + console.error('Failed to persist excluded words to stats database', error); + throw error; } - console.error('Failed to persist excluded words to stats database', error); - throw error; - } + // A newer edit arrived while this write was in flight, so the server state + // this acknowledges is already obsolete. Its own acknowledgement notifies + // with the newest list; skipping here avoids a wasted aggregate scan. + if (revision === writeRevision) notifyServerSync(); + }); + writeChain = write.catch(() => {}); + return write; } export function initializeExcludedWordsStore(): Promise { @@ -155,6 +192,8 @@ export function resetExcludedWordsStoreForTests(): void { initialized = null; revision = 0; listeners.clear(); + serverSyncListeners.clear(); + writeChain = Promise.resolve(); } function subscribe(fn: () => void): () => void { diff --git a/stats/src/hooks/useVocabulary.test.tsx b/stats/src/hooks/useVocabulary.test.tsx new file mode 100644 index 00000000..93db57a2 --- /dev/null +++ b/stats/src/hooks/useVocabulary.test.tsx @@ -0,0 +1,447 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Window } from 'happy-dom'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { apiClient } from '../lib/api-client'; +import { resetExcludedWordsStoreForTests, setExcludedWords } from './useExcludedWords'; +import { useVocabulary } from './useVocabulary'; +import type { StatsVocabularyCharts, StatsVocabularySummary } from '../types/stats'; + +type VocabularyState = ReturnType; + +function installDom(): () => void { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const previousHTMLElement = Object.getOwnPropertyDescriptor(globalThis, 'HTMLElement'); + const previousIsReactActEnvironment = Object.getOwnPropertyDescriptor( + globalThis, + 'IS_REACT_ACT_ENVIRONMENT', + ); + const window = new Window(); + + Object.defineProperty(globalThis, 'window', { value: window, configurable: true }); + Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true }); + Object.defineProperty(globalThis, 'HTMLElement', { + value: window.HTMLElement, + configurable: true, + }); + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + value: true, + configurable: true, + writable: true, + }); + + return () => { + const restoreProperty = (name: string, descriptor: PropertyDescriptor | undefined) => { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + }; + restoreProperty('window', previousWindow); + restoreProperty('document', previousDocument); + restoreProperty('HTMLElement', previousHTMLElement); + restoreProperty('IS_REACT_ACT_ENVIRONMENT', previousIsReactActEnvironment); + }; +} + +test('DOM harness restores the original global property descriptors', () => { + const propertyNames = ['window', 'document', 'HTMLElement', 'IS_REACT_ACT_ENVIRONMENT'] as const; + const before = propertyNames.map((name) => Object.getOwnPropertyDescriptor(globalThis, name)); + + const restore = installDom(); + restore(); + + const after = propertyNames.map((name) => Object.getOwnPropertyDescriptor(globalThis, name)); + assert.deepEqual(after, before); +}); + +function installLocalStorage(): () => void { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + const values = new Map(); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }, + }); + return () => { + if (previous) Object.defineProperty(globalThis, 'localStorage', previous); + else delete (globalThis as { localStorage?: unknown }).localStorage; + }; +} + +interface FakeClock { + tick: (ms: number) => void; + restore: () => void; +} + +/** Bun's `node:test` shim has no `mock.timers`, so the retry clock is faked here. */ +function installFakeTimers(): FakeClock { + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const timers = new Map void }>(); + let now = 0; + let nextId = 1; + + globalThis.setTimeout = ((fn: () => void, delay = 0) => { + const id = nextId; + nextId += 1; + timers.set(id, { at: now + delay, fn }); + return id; + }) as unknown as typeof globalThis.setTimeout; + globalThis.clearTimeout = ((id: number) => { + timers.delete(id); + }) as unknown as typeof globalThis.clearTimeout; + + return { + tick: (ms: number) => { + now += ms; + const due = [...timers.entries()] + .filter(([, timer]) => timer.at <= now) + .sort(([, a], [, b]) => a.at - b.at); + for (const [id, timer] of due) { + timers.delete(id); + timer.fn(); + } + }, + restore: () => { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + }, + }; +} + +function summaryFixture(): StatsVocabularySummary { + return { + uniqueWords: 42, + uniqueWordsWithoutNames: 40, + uniqueKanji: 7, + newThisWeek: 3, + newThisWeekWithoutNames: 2, + knownWordCount: 10, + knownWordCountWithoutNames: 9, + }; +} + +function chartsFixture(overrides: Partial = {}): StatsVocabularyCharts { + return { + ready: true, + topWords: [{ wordId: 1, headword: '猫', frequency: 5 }], + topWordsWithoutNames: [{ wordId: 1, headword: '猫', frequency: 5 }], + newWordsTimeline: [{ epochDay: 20_000, wordCount: 4 }], + newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 4 }], + ...overrides, + }; +} + +interface Harness { + state: () => VocabularyState; + flush: () => Promise; + tick: (ms: number) => Promise; + unmount: () => Promise; + teardown: () => Promise; +} + +async function mountHook(): Promise { + const uninstallDom = installDom(); + const uninstallLocalStorage = installLocalStorage(); + const clock = installFakeTimers(); + + let latest: VocabularyState | null = null; + function Probe() { + latest = useVocabulary(); + return null; + } + + const container = document.createElement('div'); + document.body.append(container); + let root: Root | null = createRoot(container); + await act(async () => { + root!.render(); + }); + + const flush = async (): Promise => { + // Drain promise callbacks without advancing the mocked clock. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + }; + + return { + state: () => { + assert.ok(latest, 'expected the hook to have rendered'); + return latest; + }, + flush, + tick: async (ms: number) => { + await act(async () => { + clock.tick(ms); + }); + await flush(); + }, + unmount: async () => { + await act(async () => { + root?.unmount(); + root = null; + }); + }, + teardown: async () => { + await act(async () => { + root?.unmount(); + root = null; + }); + clock.restore(); + // React's scheduler can still have deferred work queued; let it drain on + // a real timer while the DOM globals it reads are still installed. + await new Promise((resolve) => setTimeout(resolve, 0)); + uninstallLocalStorage(); + uninstallDom(); + resetExcludedWordsStoreForTests(); + }, + }; +} + +function stubVocabularyClient(overrides: { + getVocabularySummary: () => Promise; + getVocabularyCharts: () => Promise; +}): () => void { + const original = { + getVocabulary: apiClient.getVocabulary, + getKanji: apiClient.getKanji, + getKnownWords: apiClient.getKnownWords, + getVocabularySummary: apiClient.getVocabularySummary, + getVocabularyCharts: apiClient.getVocabularyCharts, + setExcludedWords: apiClient.setExcludedWords, + }; + apiClient.getVocabulary = async () => []; + apiClient.getKanji = async () => []; + apiClient.getKnownWords = async () => []; + apiClient.setExcludedWords = async () => {}; + apiClient.getVocabularySummary = overrides.getVocabularySummary; + apiClient.getVocabularyCharts = overrides.getVocabularyCharts; + return () => Object.assign(apiClient, original); +} + +test('aggregate failures retry with backoff, then surface an error that Retry clears', async () => { + const originalConsoleError = console.error; + console.error = () => {}; + let summaryCalls = 0; + let failSummary = true; + const restoreClient = stubVocabularyClient({ + getVocabularySummary: async () => { + summaryCalls += 1; + if (failSummary) throw new Error('summary unavailable'); + return summaryFixture(); + }, + getVocabularyCharts: async () => chartsFixture(), + }); + const harness = await mountHook(); + + try { + await harness.flush(); + assert.equal(summaryCalls, 1); + assert.equal(harness.state().aggregatesError, null, 'no error until retries are exhausted'); + + // Backoff is 1s, 2s, 4s, 8s across the remaining four attempts. + for (const delayMs of [1_000, 2_000, 4_000, 8_000]) { + await harness.tick(delayMs); + } + assert.equal(summaryCalls, 5, 'retries are bounded at the attempt limit'); + assert.match(harness.state().aggregatesError ?? '', /totals failed to load/i); + + // Nothing further is scheduled once the limit is reached. + await harness.tick(60_000); + assert.equal(summaryCalls, 5); + + failSummary = false; + await act(async () => { + harness.state().refreshAggregates(); + }); + await harness.flush(); + + assert.equal(summaryCalls, 6); + assert.equal(harness.state().aggregatesError, null); + assert.deepEqual(harness.state().summary, summaryFixture()); + } finally { + await harness.teardown(); + restoreClient(); + console.error = originalConsoleError; + } +}); + +test('charts poll while the backfill is pending and stop once it is ready', async () => { + let chartCalls = 0; + const restoreClient = stubVocabularyClient({ + getVocabularySummary: async () => summaryFixture(), + getVocabularyCharts: async () => { + chartCalls += 1; + return chartsFixture({ ready: chartCalls >= 3 }); + }, + }); + const harness = await mountHook(); + + try { + await harness.flush(); + assert.equal(chartCalls, 1); + assert.equal(harness.state().charts?.ready, false); + + await harness.tick(1_000); + assert.equal(chartCalls, 2); + await harness.tick(1_000); + assert.equal(chartCalls, 3); + assert.equal(harness.state().charts?.ready, true); + + // A ready result ends the poll. + await harness.tick(60_000); + assert.equal(chartCalls, 3); + } finally { + await harness.teardown(); + restoreClient(); + } +}); + +test('chart backfill polling stops and surfaces Retry when readiness never arrives', async () => { + let chartCalls = 0; + const restoreClient = stubVocabularyClient({ + getVocabularySummary: async () => summaryFixture(), + getVocabularyCharts: async () => { + chartCalls += 1; + return chartsFixture({ ready: false }); + }, + }); + const harness = await mountHook(); + + try { + await harness.flush(); + for (let poll = 0; poll < 65; poll += 1) await harness.tick(5_000); + + assert.equal(chartCalls, 60, 'a failed backfill must not poll for the lifetime of the tab'); + assert.match(harness.state().aggregatesError ?? '', /still building/i); + + await act(async () => { + harness.state().refreshAggregates(); + }); + await harness.flush(); + assert.equal(chartCalls, 61, 'Retry starts one fresh bounded polling cycle'); + assert.equal(harness.state().aggregatesError, null); + } finally { + await harness.teardown(); + restoreClient(); + } +}); + +test('aggregates refetch after an exclusion edit is acknowledged by the server', async () => { + let summaryCalls = 0; + let chartCalls = 0; + const restoreClient = stubVocabularyClient({ + getVocabularySummary: async () => { + summaryCalls += 1; + return summaryFixture(); + }, + getVocabularyCharts: async () => { + chartCalls += 1; + return chartsFixture(); + }, + }); + const harness = await mountHook(); + + try { + await harness.flush(); + assert.equal(summaryCalls, 1); + assert.equal(chartCalls, 1); + + await act(async () => { + await setExcludedWords([{ headword: '猫', word: '猫', reading: 'ねこ' }]); + }); + await harness.flush(); + + assert.equal(summaryCalls, 2, 'totals must not keep counting the excluded word'); + assert.equal(chartCalls, 2); + } finally { + await harness.teardown(); + restoreClient(); + } +}); + +test('pending retries are cancelled when the tab unmounts', async () => { + const originalConsoleError = console.error; + console.error = () => {}; + let summaryCalls = 0; + const restoreClient = stubVocabularyClient({ + getVocabularySummary: async () => { + summaryCalls += 1; + throw new Error('summary unavailable'); + }, + getVocabularyCharts: async () => chartsFixture(), + }); + const harness = await mountHook(); + + try { + await harness.flush(); + assert.equal(summaryCalls, 1); + + await harness.unmount(); + await harness.tick(60_000); + + assert.equal(summaryCalls, 1, 'no retry may run after unmount'); + } finally { + await harness.teardown(); + restoreClient(); + console.error = originalConsoleError; + } +}); + +test('a slow response from a superseded refresh cannot replace the newest aggregates', async () => { + let summaryCalls = 0; + let releaseSuperseded: (() => void) | null = null; + const restoreClient = stubVocabularyClient({ + getVocabularySummary: async () => { + summaryCalls += 1; + const call = summaryCalls; + // The second call is the one that gets superseded while still in flight. + if (call === 2) { + await new Promise((resolve) => { + releaseSuperseded = resolve; + }); + } + return { ...summaryFixture(), uniqueWords: call }; + }, + getVocabularyCharts: async () => chartsFixture(), + }); + const harness = await mountHook(); + + try { + await harness.flush(); + assert.equal(harness.state().summary?.uniqueWords, 1); + + // First refresh stalls, then a second refresh supersedes it and resolves. + await act(async () => { + harness.state().refreshAggregates(); + }); + await harness.flush(); + await act(async () => { + harness.state().refreshAggregates(); + }); + await harness.flush(); + + assert.equal(summaryCalls, 3); + assert.equal(harness.state().summary?.uniqueWords, 3); + + const release = releaseSuperseded as (() => void) | null; + assert.ok(release, 'expected the superseded request to still be in flight'); + release(); + await harness.flush(); + + assert.equal( + harness.state().summary?.uniqueWords, + 3, + 'the superseded response must not overwrite the newest totals', + ); + } finally { + await harness.teardown(); + restoreClient(); + } +}); diff --git a/stats/src/hooks/useVocabulary.ts b/stats/src/hooks/useVocabulary.ts index 943cd752..bc1eb3f1 100644 --- a/stats/src/hooks/useVocabulary.ts +++ b/stats/src/hooks/useVocabulary.ts @@ -1,16 +1,46 @@ import { useState, useEffect, useCallback } from 'react'; import { getStatsClient } from './useStatsApi'; -import type { VocabularyEntry, KanjiEntry } from '../types/stats'; +import { subscribeExcludedWordsServerSync } from './useExcludedWords'; +import type { + VocabularyEntry, + KanjiEntry, + StatsVocabularyCharts, + StatsVocabularySummary, +} from '../types/stats'; + +const AGGREGATE_RETRY_BASE_MS = 1_000; +const AGGREGATE_RETRY_MAX_MS = 30_000; +const AGGREGATE_RETRY_LIMIT = 5; +const CHART_BACKFILL_POLL_MS = 1_000; +const CHART_BACKFILL_SLOW_POLL_MS = 5_000; +const CHART_BACKFILL_FAST_POLLS = 30; +const CHART_BACKFILL_POLL_LIMIT = 60; + +function aggregateRetryDelayMs(attempt: number): number { + return Math.min(AGGREGATE_RETRY_BASE_MS * 2 ** attempt, AGGREGATE_RETRY_MAX_MS); +} export function useVocabulary() { const [words, setWords] = useState([]); const [kanji, setKanji] = useState([]); const [knownWords, setKnownWords] = useState>(new Set()); + const [summary, setSummary] = useState(null); + const [charts, setCharts] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [aggregatesError, setAggregatesError] = useState(null); // Bumped by `reload` after maintenance rewrites the vocabulary tables. const [reloadToken, setReloadToken] = useState(0); - const reload = useCallback(() => setReloadToken((token) => token + 1), []); + // Bumped independently when only the server-computed summary/charts are + // stale, e.g. after the exclusion list changes on the server. + const [aggregatesToken, setAggregatesToken] = useState(0); + const refreshAggregates = useCallback(() => setAggregatesToken((token) => token + 1), []); + const reload = useCallback(() => { + setReloadToken((token) => token + 1); + setAggregatesToken((token) => token + 1); + }, []); + + useEffect(() => subscribeExcludedWordsServerSync(refreshAggregates), [refreshAggregates]); useEffect(() => { let cancelled = false; @@ -51,5 +81,88 @@ export function useVocabulary() { }; }, [reloadToken]); - return { words, kanji, knownWords, loading, error, reload }; + useEffect(() => { + let cancelled = false; + setAggregatesError(null); + const client = getStatsClient(); + const timers = new Set>(); + const schedule = (fn: () => void, delayMs: number): void => { + const timer = setTimeout(() => { + timers.delete(timer); + fn(); + }, delayMs); + timers.add(timer); + }; + + const loadSummary = (attempt: number): void => { + void client + .getVocabularySummary() + .then((nextSummary) => { + if (!cancelled) setSummary(nextSummary); + }) + .catch((summaryError: unknown) => { + console.error('Failed to load vocabulary summary', summaryError); + if (cancelled) return; + if (attempt + 1 < AGGREGATE_RETRY_LIMIT) { + schedule(() => loadSummary(attempt + 1), aggregateRetryDelayMs(attempt)); + } else { + setAggregatesError((previous) => previous ?? 'Vocabulary totals failed to load.'); + } + }); + }; + + const loadCharts = (attempt: number, readyPolls: number): void => { + void client + .getVocabularyCharts() + .then((nextCharts) => { + if (cancelled) return; + setCharts(nextCharts); + if (!nextCharts.ready) { + const completedPolls = readyPolls + 1; + if (completedPolls < CHART_BACKFILL_POLL_LIMIT) { + schedule( + () => loadCharts(0, completedPolls), + completedPolls < CHART_BACKFILL_FAST_POLLS + ? CHART_BACKFILL_POLL_MS + : CHART_BACKFILL_SLOW_POLL_MS, + ); + } else { + setAggregatesError( + (previous) => + previous ?? 'Vocabulary charts are still building. Retry to check again.', + ); + } + } + }) + .catch((chartError: unknown) => { + console.error('Failed to load vocabulary charts', chartError); + if (cancelled) return; + if (attempt + 1 < AGGREGATE_RETRY_LIMIT) { + schedule(() => loadCharts(attempt + 1, readyPolls), aggregateRetryDelayMs(attempt)); + } else { + setAggregatesError((previous) => previous ?? 'Vocabulary charts failed to load.'); + } + }); + }; + + loadSummary(0); + loadCharts(0, 0); + return () => { + cancelled = true; + for (const timer of timers) clearTimeout(timer); + }; + }, [aggregatesToken]); + + return { + words, + kanji, + knownWords, + summary, + charts, + loading, + error, + aggregatesError, + refreshAggregates, + reload, + }; } diff --git a/stats/src/lib/api-client.ts b/stats/src/lib/api-client.ts index d3aa11f5..addf6ef6 100644 --- a/stats/src/lib/api-client.ts +++ b/stats/src/lib/api-client.ts @@ -100,6 +100,8 @@ export const apiClient = { getSessionKnownWordsTimeline: (id: number) => fetchJson('sessionKnownWordsTimeline', `/api/stats/sessions/${id}/known-words-timeline`), getVocabulary: (limit = 100) => fetchJson('vocabulary', `/api/stats/vocabulary?limit=${limit}`), + getVocabularySummary: () => fetchJson('vocabularySummary', '/api/stats/vocabulary/summary'), + getVocabularyCharts: () => fetchJson('vocabularyCharts', '/api/stats/vocabulary/charts'), getExcludedWords: () => fetchJson('excludedWords', '/api/stats/excluded-words'), setExcludedWords: async (words: StatsExcludedWord[]): Promise => { await fetchResponse('/api/stats/excluded-words', { diff --git a/stats/src/lib/formatters.test.ts b/stats/src/lib/formatters.test.ts index ca960aed..1f60926a 100644 --- a/stats/src/lib/formatters.test.ts +++ b/stats/src/lib/formatters.test.ts @@ -1,7 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { epochMsFromDbTimestamp, formatRelativeDate, formatSessionDayLabel } from './formatters'; +import { + epochDayToDate, + epochMsFromDbTimestamp, + formatRelativeDate, + formatSessionDayLabel, +} from './formatters'; const FIXED_NOW = new Date(2026, 2, 16, 12, 0, 0).getTime(); @@ -108,6 +113,19 @@ test('epochMsFromDbTimestamp keeps ms timestamps as-is', () => { assert.equal(epochMsFromDbTimestamp(1_700_000_000_000), 1_700_000_000_000); }); +test('epochDayToDate preserves the calendar day west of UTC', () => { + const previousTimezone = process.env.TZ; + process.env.TZ = 'America/Los_Angeles'; + try { + const epochDay = Math.floor(Date.UTC(2026, 2, 16) / 86_400_000); + const date = epochDayToDate(epochDay); + assert.deepEqual([date.getFullYear(), date.getMonth(), date.getDate()], [2026, 2, 16]); + } finally { + if (previousTimezone === undefined) delete process.env.TZ; + else process.env.TZ = previousTimezone; + } +}); + test('formatSessionDayLabel formats today and yesterday', () => { withFixedNow((now) => { const oneDayMs = 24 * 60 * 60_000; diff --git a/stats/src/lib/formatters.ts b/stats/src/lib/formatters.ts index 7923e747..0184d154 100644 --- a/stats/src/lib/formatters.ts +++ b/stats/src/lib/formatters.ts @@ -38,7 +38,8 @@ export function formatRelativeDate(ms: number): string { } export function epochDayToDate(epochDay: number): Date { - return new Date(epochDay * 86_400_000); + const utcDate = new Date(epochDay * 86_400_000); + return new Date(utcDate.getUTCFullYear(), utcDate.getUTCMonth(), utcDate.getUTCDate()); } export function localDayFromMs(ms: number): number { diff --git a/stats/src/lib/vocabulary-tab.test.ts b/stats/src/lib/vocabulary-tab.test.ts index a5dc52cf..1df79380 100644 --- a/stats/src/lib/vocabulary-tab.test.ts +++ b/stats/src/lib/vocabulary-tab.test.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; const VOCABULARY_TAB_PATH = fileURLToPath( new URL('../components/vocabulary/VocabularyTab.tsx', import.meta.url), ); +const VOCABULARY_HOOK_PATH = fileURLToPath(new URL('../hooks/useVocabulary.ts', import.meta.url)); test('VocabularyTab declares all hooks before loading and error early returns', () => { const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8'); @@ -20,15 +21,32 @@ test('VocabularyTab declares all hooks before loading and error early returns', assert.deepEqual(hooksAfterLoadingGuard ?? [], []); }); -test('VocabularyTab memoizes summary and known-word aggregate calculations', () => { +test('VocabularyTab uses uncapped server-side data for its charts and card totals', () => { const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8'); + assert.match(source, /\} = useVocabulary\(\);/); + assert.match(source, /charts\?\.topWordsWithoutNames/); + assert.match(source, /charts\?\.newWordsTimelineWithoutNames/); + assert.doesNotMatch(source, /buildVocabularySummary\(/); + assert.match(source, /uniqueWords: summary\?\.uniqueWordsWithoutNames \?\? 0/); + assert.match(source, /uniqueWords: summary\?\.uniqueWords \?\? 0/); + assert.match(source, /value=\{summary \? formatNumber\(summary\.uniqueKanji\) : '…'\}/); +}); + +test('VocabularyTab surfaces aggregate failures with a retry control', () => { + const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8'); + + assert.match(source, /aggregatesError/); + assert.match(source, /onClick=\{refreshAggregates\}/); +}); + +test('useVocabulary loads exact card totals without holding up the vocabulary tables', () => { + const source = fs.readFileSync(VOCABULARY_HOOK_PATH, 'utf8'); + assert.match( source, - /const summary = useMemo\([\s\S]*buildVocabularySummary\(filteredWords, kanji\)[\s\S]*\[filteredWords, kanji\][\s\S]*\);/, - ); - assert.match( - source, - /const knownWordCount = useMemo\(\(\) => \{[\s\S]*for \(const w of filteredWords\) \{[\s\S]*knownWords\.has\(w\.headword\)[\s\S]*\}\s*return count;\s*\}, \[filteredWords, knownWords\]\);/, + /Promise\.allSettled\(\[\s*client\.getVocabulary\(500\),\s*client\.getKanji\(200\),\s*client\.getKnownWords\(\),?\s*\]\)/, ); + assert.match(source, /client\s*\.getVocabularySummary\(\)\s*\.then\(/); + assert.match(source, /client\s*\.getVocabularyCharts\(\)/); });