mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-24 12:15:27 -07:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7358507b1
|
||
|
|
b029cc73a1
|
||
|
|
60432ca2f3
|
||
|
|
9044340676
|
||
|
|
6d1a1b841a
|
||
|
|
0ac5db1c92
|
||
|
|
4635bfb264
|
||
|
|
1717d2d3f2
|
||
|
|
9f08adbfb9
|
||
|
|
6f52008e5d
|
||
|
|
c4284d1dd4
|
||
|
|
da2a212434
|
||
|
|
faab084588
|
||
|
|
c87dcd6239
|
||
|
|
ed7d3f4c3d | ||
|
|
509dc5bf7f | ||
|
|
0a0aa3ec98
|
||
|
|
b87cc3cfdd
|
||
|
|
8cb3c8c90a
|
||
|
|
03ea903927
|
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -297,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 <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
|
||||
|
||||
@@ -327,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
|
||||
|
||||
@@ -296,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
|
||||
|
||||
@@ -345,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:
|
||||
@@ -421,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 \
|
||||
@@ -433,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"
|
||||
@@ -451,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
|
||||
@@ -460,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -49,6 +49,7 @@ How fragments turn into a release:
|
||||
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 <previous tag>` 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
type: fixed
|
||||
area: subtitles
|
||||
|
||||
- Typeset ASS karaoke and animated signs no longer flood the primary overlay, subtitle sidebar, immersion history, or sentence mining with repeated glyph fragments or full-line color phases. Matching timed comments and full-line boundary events recover the complete authored line without merging ordinary repeated dialogue or separately positioned signs, and dialogue spoken while a song's animation is on screen is kept intact instead of being replaced by the lyric. Entrance and exit frames that run past the authored line timing still resolve to the clean line during lyric transitions, and dialogue spoken while a song's animation is on screen enters immersion and subtitle history without the fragment lines beside it.
|
||||
- Typeset ASS karaoke and animated signs no longer flood the primary overlay, subtitle sidebar, immersion history, or sentence mining with repeated glyph fragments or full-line color phases. Matching timed comments and full-line boundary events recover the complete authored line without merging ordinary repeated dialogue or separately positioned signs, and dialogue spoken while a song's animation is on screen is kept intact instead of being replaced by the lyric. Entrance and exit frames that run past the authored line timing still resolve to the clean line during lyric transitions, and dialogue spoken while a song's animation is on screen enters immersion and subtitle history without the fragment lines beside it. Dense visual grids (sign walls, countdown frames, scattered glyph typesetting) stay out of the published text, while multi-row CC-style dialogue blocks and wrapped lyric rows are still published. Decorative letters that lyric effects render in symbol fonts over the syllables are dropped with the animation instead of corrupting the reconstructed line or leaking as stray cues. Karaoke highlight sweeps that repaint one syllable at a time over an already-visible lyric are suppressed instead of surfacing as rolling partial copies or lone flickering syllables beside the line, and drop-shadow glyph copies offset a few pixels from their base no longer double every syllable in the reconstructed lyric. Positioned word gaps are also recovered on lines where a single fragment carries a literal space, and between wide syllable chunks whose word gap is hidden by their own width, so reconstructed translations keep their spacing instead of running words together.
|
||||
- The secondary subtitle overlay drops layered duplicate lines from animated tracks, so a short stack of repeated words collapses to its distinct lines even when the full karaoke heuristic does not apply.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: subtitles
|
||||
|
||||
- Embedded subtitle tracks on network-mounted (SMB/NFS) media are extracted and parsed again, restoring full karaoke reconstruction, sidebar cues, and mining for releases that ship subtitles only inside the container. Extraction reads the whole file once per episode (roughly 10 seconds per GB on gigabit), its timeout now accommodates large Bluray remuxes, and duplicate extraction requests share one ffmpeg process. Only true remote URLs keep the live-text-only path.
|
||||
- Live subtitle text from per-glyph typeset karaoke no longer shows a wall of scattered letters in the overlays while extraction is still running or when no parsed cues exist (remote URLs, unreadable sources); the glyph wall and its typed-syllable fragments are suppressed while concurrent dialogue lines remain.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- The macOS window-tracking helper is now built for macOS 12.0+, so the overlay attaches to mpv on older systems (previously the helper required the macOS version of the build machine and crashed on e.g. Ventura, leaving the overlay stuck on "Overlay loading").
|
||||
@@ -1,4 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become one concatenated secondary line. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display.
|
||||
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Fragmented ASS karaoke keeps spaces authored at event boundaries and recovers Latin word spaces encoded only by positioned fragment gaps, including word gaps measured across wide glyphs that width normalization alone reads as ordinary letter advances. Progressive karaoke highlights, offset shadow copies, overlapping decorative glyphs, and sign textures remain suppressed, including clipped repeated-glyph mask strips without font overrides and texture payloads that switch actor or font and use nearly transparent random text. Canonical lyrics now advance when their generated entrance begins, so word-by-word opening effects appear as one sentence instead of stacked rows during the lead-in. Wrapped lyrics also remain intact when a timed token repeats at another horizontal position. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become concatenated primary or secondary lines. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display. A failed source refresh also clears ASS-only cleanup so fallback text from other formats stays intact.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Immersion statistics storage now applies its SQLite busy timeout before WAL setup, avoiding transient database-lock failures when worker connections overlap.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: changed
|
||||
area: release
|
||||
|
||||
- Prerelease notes now open with a "Changes since" section that lists only what changed compared to the previous beta/RC of the same version, above the cumulative highlights.
|
||||
- CI now rejects prerelease tags whose committed notes were generated for a different beta/RC, instead of silently shipping stale notes.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Secondary subtitle overlays now show every rendered line instead of clipping text after roughly four lines.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -108,9 +108,11 @@ 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. Long lines repeated as dialogue and positioned signs are treated as the same line 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. When SubMiner must use mpv's live text as a fallback, it still filters full-line duplicates while preserving short repeated dialogue.
|
||||
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
|
||||
|
||||
|
||||
+11
-6
@@ -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 <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 <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 <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<version>`.
|
||||
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 <previous tag>` 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 <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 `<details><summary>Internal changes</summary>` 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 @<author> in #<pr>`, 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/<sha>/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 <version>` locally, commit the polished output, then tag.
|
||||
|
||||
@@ -87,7 +87,9 @@ interface SubtitleCue {
|
||||
|
||||
ASS scripts can also redraw one complete lyric for two or more long color/highlight phases. Those flush-timed phases collapse separately from short animation frames when they share text, style, actor, and layer and carry direct animation evidence, such as temporal tags or changing non-spatial overrides. Spatial command changes do not prove a phase, so separately positioned signs remain distinct.
|
||||
|
||||
**Canonical animation recovery.** Some ASS producers keep the readable lyric or sign as a timed `Comment:` and generate hundreds of `Dialogue:` frames containing repeated glyphs or changing clip regions. Others retain the complete line as brief `Dialogue:` events around the generated fragments. A complete event is promoted only when nearby dialogue from the same style and actor forms a proven animation cluster and reconstructs its entire text in source order. The generated frames are then replaced by one cue marked `source: 'canonical-ass'`. This source marker lets the live primary-subtitle path prefer the clean authored text and timing for display, sidebar history, immersion recording, and mining, while unmatched editor notes and alternative translations remain ignored.
|
||||
**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
|
||||
|
||||
|
||||
@@ -97,20 +97,31 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
||||
|
||||
## Secondary Subtitle Flow
|
||||
|
||||
- `secondary-sub-text` remains the immediate fallback, so unreadable and remote subtitle sources
|
||||
still appear without waiting for file resolution.
|
||||
- Parsed secondary text and the live fallback share a flattened-line identity for long lines. This
|
||||
removes dialogue/sign repetitions that differ only in whitespace or terminal punctuation while
|
||||
retaining short repeated lines that can represent authored dialogue without source metadata.
|
||||
- `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 positioned parts that span multiple vertical rows as a grid.
|
||||
Secondary text omits those grids instead of flattening a translated table or schedule into one
|
||||
synthetic line. Reconstructed single-line karaoke remains eligible for display.
|
||||
- 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.
|
||||
@@ -119,10 +130,12 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
||||
- Simultaneous ASS lines are flattened in top-to-bottom positioned order, falling back to their
|
||||
authored source order when no usable position exists.
|
||||
- Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces
|
||||
survive concatenation, while scripts that discarded their word boundaries remain compact
|
||||
instead of gaining false 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.
|
||||
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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.19.4-beta.3",
|
||||
"version": "0.19.4-beta.4",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -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",
|
||||
|
||||
+15
-13
@@ -6,39 +6,41 @@
|
||||
### Added
|
||||
|
||||
- Library Merge & Reassignment
|
||||
- Duplicate library entries for the same show can now be merged: pick 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 library entry with a per-episode "→" button, fixing cases where a stray filename split off its own entry; manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair, and other episodes in the same folder pick up the same fix automatically unless they already belong elsewhere.
|
||||
- Exact AniList matches with compatible seasons now merge automatically, and likely (fuzzy) matches surface as a dismissible "Possible duplicate" suggestion instead of merging silently; entries with conflicting seasons are left alone either way.
|
||||
- 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 you confirm it; a matching `subminer stats cleanup --duplicate-lines` command (with `--dry-run` and `--lookback-days <n>`) is available from the terminal.
|
||||
- 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 <n>`) 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
|
||||
|
||||
- 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. Dialogue spoken while a song's animation is on screen is kept intact instead of being replaced by the lyric, 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 now shares the same deduplication logic as the primary overlay, including collapsing lines that only differ by whitespace or trailing punctuation, so layered animation text and dense multi-row sign layouts no longer appear duplicated or garbled there or in what gets mined.
|
||||
- Sidebar navigation now moves between the clean, sanitized lyric lines instead of the raw generated animation events, and selecting an overlapping lyric keeps the right line selected.
|
||||
- Vocabulary stats no longer count every animation frame of a karaoke opening as a separate line, which previously could push an OP lyric to the top of "Top Repeated Words."
|
||||
- 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 (bounded probing plus a two-minute extraction budget), and a failed extraction now reports a clear error instead of a raw `ENOENT`.
|
||||
- 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.
|
||||
- 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, so subtitle and video files dropped on the overlay reach mpv.
|
||||
- Fixed system-wide mouse lag on Windows caused by the overlay's click-through handling and repeated mpv window lookups.
|
||||
- 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, 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).
|
||||
- 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.
|
||||
|
||||
@@ -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';
|
||||
@@ -583,7 +584,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 +606,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, /<!-- prerelease-base-version: 0\.11\.3 -->/);
|
||||
assert.match(prereleaseNotes, /<!-- prerelease-version: 0\.11\.3-beta\.1 -->/);
|
||||
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 +670,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 +725,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 +792,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 +832,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 +1449,373 @@ test('writeChangelogArtifacts strips <details> 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,
|
||||
/<!-- prerelease-version: 0\.12\.0-beta\.2; since: v0\.12\.0-beta\.1 -->/,
|
||||
);
|
||||
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.',
|
||||
'',
|
||||
'<!-- prerelease-version: 0.12.0-beta.2; since: v0.12.0-beta.1 -->',
|
||||
'',
|
||||
'## 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,
|
||||
'<!-- prerelease-version: 0.12.0-beta.2; since: v0.12.0-beta.1 -->\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,
|
||||
'<!-- prerelease-version: 0.12.0-beta.1 -->\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,
|
||||
'<!-- prerelease-base-version: 0.12.0 -->\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 });
|
||||
}
|
||||
});
|
||||
|
||||
+304
-6
@@ -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 `<!-- prerelease-base-version: ${resolvePrereleaseBaseVersion(version)} -->`;
|
||||
// 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 `<!-- prerelease-version: ${normalizeVersion(version)}${since} -->`;
|
||||
}
|
||||
|
||||
export function extractPrereleaseVersionMarker(notes: string): string | null {
|
||||
return (
|
||||
/<!--\s*prerelease-version:\s*(\d+\.\d+\.\d+-(?:beta|rc)\.\d+)(?:;\s*since:\s*\S+)?\s*-->/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 /<!--\s*prerelease-base-version:\s*(\d+\.\d+\.\d+)\s*-->/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*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\s*/u, '').trim();
|
||||
return notes
|
||||
.replace(/<!--\s*prerelease-version:[^>]*-->\s*/u, '')
|
||||
.replace(/<!--\s*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\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(
|
||||
@@ -615,7 +784,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 +796,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(/<details>[\s\S]*?<\/details>\s*/gm, '').trim();
|
||||
}
|
||||
@@ -709,15 +947,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 +989,7 @@ function writeReleaseNotesFile(
|
||||
contributions?: Contribution[];
|
||||
contributorSections?: string[];
|
||||
metadata?: string[];
|
||||
deltaSection?: string[];
|
||||
},
|
||||
): string {
|
||||
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
||||
@@ -1079,6 +1321,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 <previous tag>" 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 +1357,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 +1499,11 @@ function main(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'check-prerelease-notes') {
|
||||
verifyPrereleaseNotesMatchVersion(options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'docs') {
|
||||
generateDocsChangelog(options);
|
||||
return;
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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\}/);
|
||||
});
|
||||
|
||||
@@ -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,18 @@ 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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -315,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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
@@ -1021,3 +1027,752 @@ 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 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 a tall CC-style dialogue block publishable, not a fragment grid', () => {
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(212,383)\\fscx50\\fscy50}たき',
|
||||
'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 cue = parseSubtitleCues(content, 'test.ass')[0];
|
||||
assert.equal(cue?.text, 'たき(立希)ともりお前… 燈をバンドに誘ったの?');
|
||||
assert.equal(cue?.assLayout?.kind, 'positioned');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues marks re-shown countdown frames as a fragment grid', () => {
|
||||
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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@ import {
|
||||
assOverrideSignature,
|
||||
assToPlainText,
|
||||
collectAssOverrideCommands,
|
||||
hasAssTemporalOverride,
|
||||
parseAssEffectField,
|
||||
removeAssControlDebrisLines,
|
||||
type AssEffectKind,
|
||||
type AssOverrideCommand,
|
||||
} from './ass-text';
|
||||
@@ -91,6 +93,10 @@ function sanitizeSubtitleCueText(text: string): string {
|
||||
return decodeSubtitleCueText(text).trim();
|
||||
}
|
||||
|
||||
function sanitizeAssCueText(text: string): string {
|
||||
return removeAssControlDebrisLines(decodeSubtitleCueText(text)).trim();
|
||||
}
|
||||
|
||||
function attachAssLayout<T extends SubtitleCue>(cue: T, assLayout: AssCueLayout | undefined): T {
|
||||
if (assLayout) {
|
||||
Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false });
|
||||
@@ -334,6 +340,27 @@ function fragmentPlacementAnchors(event: AnnotatedSubtitleCue): Set<string> {
|
||||
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;
|
||||
|
||||
// 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.
|
||||
function fragmentAnchorPoints(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]! + args[2]!) / 2, y: (args[1]! + args[3]!) / 2 });
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function isRepeatedFragmentCopy(
|
||||
previous: AnnotatedSubtitleCue,
|
||||
current: AnnotatedSubtitleCue,
|
||||
@@ -342,6 +369,17 @@ function isRepeatedFragmentCopy(
|
||||
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 &&
|
||||
@@ -387,6 +425,292 @@ interface AssFragmentPart {
|
||||
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<string, number>();
|
||||
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<number, Set<number>>();
|
||||
for (const part of positioned) {
|
||||
if (!part.position) continue;
|
||||
const x = Math.round(part.position.x);
|
||||
const rows = columnRows.get(x) ?? new Set<number>();
|
||||
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,
|
||||
@@ -404,7 +728,8 @@ function reconstructedAssFragmentLayout(
|
||||
|
||||
if (
|
||||
positionedPartCount >= MIN_FRAGMENT_LINE_PARTS &&
|
||||
maximumY - minimumY > MAX_FRAGMENT_LINE_VERTICAL_SPAN
|
||||
maximumY - minimumY > MAX_FRAGMENT_LINE_VERTICAL_SPAN &&
|
||||
looksLikeFragmentGridParts(parts)
|
||||
) {
|
||||
return { kind: 'fragment-grid', sourceOrder: owner.order };
|
||||
}
|
||||
@@ -479,6 +804,90 @@ function clusterAssFragmentEvents(
|
||||
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 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 {
|
||||
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 < MIN_FRAGMENT_LINE_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')
|
||||
@@ -522,10 +931,7 @@ function reconstructAssFragmentLine(
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = parts
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
.trim();
|
||||
const text = joinAssFragmentParts(parts);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
@@ -547,6 +953,197 @@ function reconstructAssFragmentLine(
|
||||
};
|
||||
}
|
||||
|
||||
// `\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;
|
||||
const MIN_TEXTURE_LAYER_ALPHA = 0xe0;
|
||||
const ASS_ALPHA_VALUE_PATTERN = /^&?H([0-9a-f]{1,2})&?$/iu;
|
||||
|
||||
function hasStaticOverride(cue: AnnotatedSubtitleCue, expectedName: string): boolean {
|
||||
return cue.overrides.some(
|
||||
(command) => !command.animated && command.name.toLowerCase() === expectedName,
|
||||
);
|
||||
}
|
||||
|
||||
function isClippedRepeatedGlyphFragment(cue: AnnotatedSubtitleCue): boolean {
|
||||
const glyphs = [...compactCueMatchText(cue)];
|
||||
return (
|
||||
glyphs.length > 0 &&
|
||||
glyphs.every((glyph) => glyph === glyphs[0]) &&
|
||||
(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;
|
||||
}
|
||||
|
||||
const glyphs = [...compactCueMatchText(cue)];
|
||||
const isClippedRepeatedGlyphRun =
|
||||
glyphs.length >= MIN_TEXTURE_GLYPH_RUN && isClippedRepeatedGlyphFragment(cue);
|
||||
if (isClippedRepeatedGlyphRun) {
|
||||
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;
|
||||
}
|
||||
const alphaValues = secondaryAlpha.map((command) => command.args.toLowerCase());
|
||||
return new Set(alphaValues).size >= 2;
|
||||
}
|
||||
|
||||
function staticGlobalAlpha(cue: AnnotatedSubtitleCue): number | null {
|
||||
let alpha: number | null = null;
|
||||
for (const command of cue.overrides) {
|
||||
if (command.animated || command.name.toLowerCase() !== 'alpha') 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 isNearlyTransparentPositionedText(cue: AnnotatedSubtitleCue): boolean {
|
||||
const alpha = staticGlobalAlpha(cue);
|
||||
return (
|
||||
alpha !== null &&
|
||||
alpha >= MIN_TEXTURE_LAYER_ALPHA &&
|
||||
staticFontOverride(cue) !== null &&
|
||||
fragmentPosition(cue) !== null
|
||||
);
|
||||
}
|
||||
|
||||
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 without carrying
|
||||
// enough tags to identify themselves. The exact style, time, and font group catches
|
||||
// those pieces without inspecting their content.
|
||||
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<string, AnnotatedSubtitleCue[]>();
|
||||
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)]),
|
||||
);
|
||||
|
||||
return {
|
||||
dialogue: events.dialogue.filter((cue) => {
|
||||
if (seedSet.has(cue)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
staticFontOverride(cue) === null &&
|
||||
noFontTextureTimings.has(assTextureTimingGroupKey(cue)) &&
|
||||
isClippedRepeatedGlyphFragment(cue)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const key = assFontTextureGroupKey(cue);
|
||||
if (key !== null && textureGroups.has(key)) {
|
||||
return false;
|
||||
}
|
||||
if (!isNearlyTransparentPositionedText(cue)) {
|
||||
return true;
|
||||
}
|
||||
const styleSeedIndex = seedIndexesByStyle.get(cue.style);
|
||||
return (
|
||||
styleSeedIndex === undefined ||
|
||||
eventsOverlappingWindow(styleSeedIndex, cue.startTime, cue.endTime).length === 0
|
||||
);
|
||||
}),
|
||||
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<AnnotatedSubtitleCue> {
|
||||
const byFont = new Map<string, AnnotatedSubtitleCue[]>();
|
||||
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<AnnotatedSubtitleCue>();
|
||||
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<string, AnnotatedSubtitleCue[]>();
|
||||
for (const cue of dialogue) {
|
||||
@@ -565,16 +1162,44 @@ function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): Annotate
|
||||
const recovered: AnnotatedSubtitleCue[] = [];
|
||||
const suppressed = new Set<AnnotatedSubtitleCue>();
|
||||
for (const events of groups.values()) {
|
||||
for (const cluster of clusterAssFragmentEvents(events)) {
|
||||
const decorative = decorativeGlyphEvents(events);
|
||||
const lineEvents = decorative.size ? events.filter((event) => !decorative.has(event)) : events;
|
||||
if (isProgressiveHighlightSweepGroup(lineEvents)) {
|
||||
lineEvents.forEach((event) => suppressed.add(event));
|
||||
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 && overlay.endTime > spanStart) {
|
||||
suppressed.add(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((event) => suppressed.add(event));
|
||||
continue;
|
||||
}
|
||||
recovered.push(line);
|
||||
cluster.events.forEach((event) => suppressed.add(event));
|
||||
// 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 && overlay.endTime > spanStart) {
|
||||
suppressed.add(overlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (recovered.length === 0) {
|
||||
if (recovered.length === 0 && suppressed.size === 0) {
|
||||
return dialogue;
|
||||
}
|
||||
return [...dialogue.filter((cue) => !suppressed.has(cue)), ...recovered].sort(
|
||||
@@ -853,6 +1478,10 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
|
||||
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]';
|
||||
@@ -883,9 +1512,9 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
continue;
|
||||
}
|
||||
|
||||
const eventPrefix = trimmed.startsWith(ASS_DIALOGUE_PREFIX)
|
||||
const eventPrefix = eventLine.startsWith(ASS_DIALOGUE_PREFIX)
|
||||
? ASS_DIALOGUE_PREFIX
|
||||
: trimmed.startsWith(ASS_COMMENT_PREFIX)
|
||||
: eventLine.startsWith(ASS_COMMENT_PREFIX)
|
||||
? ASS_COMMENT_PREFIX
|
||||
: null;
|
||||
if (!eventPrefix) {
|
||||
@@ -896,7 +1525,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fields = trimmed.slice(eventPrefix.length).split(',');
|
||||
const fields = eventLine.slice(eventPrefix.length).split(',');
|
||||
if (
|
||||
fieldIndex.start >= fields.length ||
|
||||
fieldIndex.end >= fields.length ||
|
||||
@@ -907,12 +1536,12 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -947,7 +1576,8 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
}
|
||||
|
||||
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(parseAnnotatedAssEvents(content)));
|
||||
const events = removeAssFontTextureEvents(parseAnnotatedAssEvents(content));
|
||||
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events));
|
||||
}
|
||||
|
||||
export function parseAssCues(content: string): SubtitleCue[] {
|
||||
|
||||
+9
-4
@@ -588,9 +588,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';
|
||||
|
||||
@@ -2054,10 +2055,12 @@ 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),
|
||||
});
|
||||
|
||||
@@ -2086,8 +2089,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),
|
||||
@@ -3962,6 +3965,7 @@ const {
|
||||
appState.yomitanSettingsWindow = null;
|
||||
},
|
||||
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
||||
cleanupInternalSubtitleTrackCache: () => cachedInternalSubtitleTrackExtractor.clear(),
|
||||
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
|
||||
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
|
||||
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
|
||||
@@ -4522,6 +4526,7 @@ const {
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
);
|
||||
if ((normalizedPath || null) !== previousPath) {
|
||||
cachedInternalSubtitleTrackExtractor.clear();
|
||||
secondarySubtitleTrackController.reset();
|
||||
const resetSubtitlePayload = { text: '', tokens: null };
|
||||
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -860,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\(\{(?<body>[\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/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -49,6 +49,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: async () => {},
|
||||
cleanupInternalSubtitleTrackCache: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupYoutubeMediaCache: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
|
||||
@@ -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> }) => void)
|
||||
| undefined;
|
||||
const firstExtraction = new Promise<{ path: string; cleanup: () => Promise<void> }>((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');
|
||||
|
||||
@@ -35,7 +35,21 @@ export type MpvSubtitleTrackLike = {
|
||||
'external-filename'?: unknown;
|
||||
};
|
||||
|
||||
const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000;
|
||||
export type ExtractedInternalSubtitleTrack = {
|
||||
path: string;
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type InternalSubtitleTrackExtractor = (
|
||||
ffmpegPath: string,
|
||||
videoPath: string,
|
||||
track: MpvSubtitleTrackLike,
|
||||
) => Promise<ExtractedInternalSubtitleTrack | null>;
|
||||
|
||||
// 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<void> } | null> {
|
||||
): Promise<ExtractedInternalSubtitleTrack | null> {
|
||||
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<ExtractedInternalSubtitleTrack | null>;
|
||||
};
|
||||
|
||||
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<void> => {};
|
||||
|
||||
/**
|
||||
* 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<string, CachedExtraction>();
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -426,6 +426,13 @@ test('canonical ASS cues replace live glyph spam for display, history, and immer
|
||||
text: '飛び越えてみたくて',
|
||||
source: 'canonical-ass',
|
||||
},
|
||||
{
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
text: 'MaidCafeMaidCafe',
|
||||
source: 'reconstructed-ass',
|
||||
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
|
||||
},
|
||||
],
|
||||
currentMediaPath: '/video.mkv',
|
||||
currentSubText: '',
|
||||
@@ -503,6 +510,11 @@ test('canonical ASS cues replace live glyph spam for display, history, and immer
|
||||
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', () => {
|
||||
|
||||
@@ -218,6 +218,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
return;
|
||||
}
|
||||
text = stripFragmentsForRecording(text, start);
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
if (!immersionLineDedupGate.shouldRecord({ text, startSec: start, endSec: end })) {
|
||||
return;
|
||||
}
|
||||
@@ -228,8 +231,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
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?.(
|
||||
stripFragmentsForRecording(text, start),
|
||||
recordableText,
|
||||
start,
|
||||
end,
|
||||
secondaryText,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<boolean>;
|
||||
|
||||
export function createRemoteMediaPathDetector(
|
||||
deps: {
|
||||
platform?: NodeJS.Platform;
|
||||
readMountOutput?: () => Promise<string>;
|
||||
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<readonly string[]> } | undefined;
|
||||
|
||||
const getNetworkMountPaths = (): Promise<readonly string[]> => {
|
||||
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<boolean> => {
|
||||
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));
|
||||
};
|
||||
}
|
||||
@@ -64,6 +64,34 @@ test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () =
|
||||
);
|
||||
});
|
||||
|
||||
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]',
|
||||
@@ -152,6 +180,73 @@ test('resolvePrimarySubtitleText keeps concurrent dialogue that is not part of t
|
||||
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今 手にある');
|
||||
});
|
||||
|
||||
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: '次のセリフ',
|
||||
@@ -382,3 +477,23 @@ test('resolveCanonicalPrimarySubtitle picks the cue its fragments spell, not the
|
||||
'今 手にある',
|
||||
);
|
||||
});
|
||||
|
||||
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: [],
|
||||
}),
|
||||
'それよりも ノート…',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { 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
|
||||
@@ -13,6 +17,15 @@ export interface ResolvedPrimarySubtitle {
|
||||
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,
|
||||
@@ -23,9 +36,13 @@ function animationSpan(cue: SubtitleCue): { start: number; end: number } {
|
||||
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') {
|
||||
if (
|
||||
(cue.source !== 'canonical-ass' && cue.source !== 'reconstructed-ass') ||
|
||||
(!includeFragmentGrids && cue.assLayout?.kind === 'fragment-grid')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const span = animationSpan(cue);
|
||||
@@ -37,7 +54,7 @@ function nearbyCanonicalCues(
|
||||
}
|
||||
|
||||
function compactWhitespace(text: string): string {
|
||||
return text.replace(/\s+/gu, '');
|
||||
return text.normalize('NFKC').replace(/\s+/gu, '');
|
||||
}
|
||||
|
||||
// ASS layers can encode the same visible spacing with ordinary, hard, or
|
||||
@@ -47,10 +64,12 @@ function uniqueCueTexts(cues: readonly SubtitleCue[]): string[] {
|
||||
const texts: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const cue of cues) {
|
||||
const compactText = compactWhitespace(cue.text);
|
||||
if (seen.has(compactText)) continue;
|
||||
seen.add(compactText);
|
||||
texts.push(cue.text);
|
||||
for (const line of cue.text.split('\n')) {
|
||||
const compactText = compactWhitespace(line);
|
||||
if (!compactText || seen.has(compactText)) continue;
|
||||
seen.add(compactText);
|
||||
texts.push(line);
|
||||
}
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
@@ -87,18 +106,37 @@ function resolveActiveParsedPrimarySubtitle(options: {
|
||||
return false;
|
||||
}
|
||||
const cueSegments = compactLineSegments(cue.text);
|
||||
return cueSegments.length > 0 && cueSegments.every((segment) => liveSegmentSet.has(segment));
|
||||
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 parsedSegmentSet = new Set(selected.flatMap((cue) => compactLineSegments(cue.text)));
|
||||
if (!liveSegments.every((segment) => parsedSegmentSet.has(segment))) {
|
||||
const parsedSegments = selected.flatMap((cue) =>
|
||||
compactLineSegments(cue.text).map((segment) => ({
|
||||
segment,
|
||||
recovered: cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass',
|
||||
})),
|
||||
);
|
||||
if (
|
||||
!liveSegments.every((liveSegment) =>
|
||||
parsedSegments.some(({ segment, recovered }) =>
|
||||
recovered ? segment.includes(liveSegment) : segment === liveSegment,
|
||||
),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const texts = uniqueCueTexts(selected);
|
||||
// Dense sign grids still explain their raw mpv fragments, but are visual
|
||||
// typesetting rather than a publishable subtitle line.
|
||||
const texts = uniqueCueTexts(selected.filter((cue) => cue.assLayout?.kind !== 'fragment-grid'));
|
||||
return {
|
||||
text: texts.join('\n'),
|
||||
startTime: Math.min(...selected.map((cue) => cue.startTime)),
|
||||
@@ -179,8 +217,9 @@ export function resolveCanonicalPrimarySubtitle(options: {
|
||||
/**
|
||||
* 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. Returns
|
||||
* the input unchanged when no canonical cue is near or nothing non-fragment remains.
|
||||
* 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;
|
||||
@@ -188,18 +227,20 @@ export function stripCanonicalFragmentLines(options: {
|
||||
cues: readonly SubtitleCue[] | null | undefined;
|
||||
}): string {
|
||||
if (!Number.isFinite(options.currentTimeSec)) {
|
||||
return options.liveText;
|
||||
return removeLiveGlyphFragmentLines(options.liveText);
|
||||
}
|
||||
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec);
|
||||
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec, true);
|
||||
if (nearby.length === 0) {
|
||||
return options.liveText;
|
||||
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));
|
||||
});
|
||||
return kept.length > 0 ? kept.join('\n') : options.liveText;
|
||||
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: {
|
||||
@@ -207,16 +248,19 @@ export function resolvePrimarySubtitleText(options: {
|
||||
currentTimeSec: number;
|
||||
cues: readonly SubtitleCue[] | null | undefined;
|
||||
}): string {
|
||||
if (!options.liveText.trim()) {
|
||||
return options.liveText;
|
||||
const liveText = cuesUseAssSyntax(options.cues)
|
||||
? removeAssControlDebrisLines(options.liveText)
|
||||
: options.liveText;
|
||||
if (!liveText.trim()) {
|
||||
return liveText;
|
||||
}
|
||||
return (
|
||||
resolveCanonicalPrimarySubtitle({
|
||||
liveText: options.liveText,
|
||||
liveText,
|
||||
currentTimeSec: options.currentTimeSec,
|
||||
cues: options.cues,
|
||||
})?.text ??
|
||||
resolveActiveParsedPrimarySubtitle(options)?.text ??
|
||||
options.liveText
|
||||
resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ??
|
||||
removeLiveGlyphFragmentLines(liveText)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,32 @@ test('findActiveSubtitleText combines unique simultaneous parsed cues', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
@@ -144,6 +170,30 @@ test('findActiveSubtitleText keeps a canonical ASS cue for its generated animati
|
||||
assert.equal(findActiveSubtitleText([poof], 1111.59), '');
|
||||
});
|
||||
|
||||
test('findActiveSubtitleText advances when the next canonical lyric animation starts', () => {
|
||||
const cues = [
|
||||
{
|
||||
startTime: 121.73,
|
||||
endTime: 124.1,
|
||||
text: 'Torn at the seams, a sound pours out',
|
||||
source: 'canonical-ass' as const,
|
||||
animationStartTime: 121.4,
|
||||
animationEndTime: 124.1,
|
||||
},
|
||||
{
|
||||
startTime: 124.13,
|
||||
endTime: 126.38,
|
||||
text: '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,
|
||||
@@ -181,6 +231,41 @@ test('ASS fragment karaoke stays separated by style with authored word spacing',
|
||||
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,
|
||||
@@ -345,6 +430,125 @@ test('secondary track controller falls back to live mpv text without a readable
|
||||
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;
|
||||
@@ -436,3 +640,36 @@ test('secondary track controller ignores and cleans up a refresh invalidated by
|
||||
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, 'それよりも ノート…');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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;
|
||||
@@ -23,6 +27,11 @@ type SecondarySubtitleSourceInput = {
|
||||
|
||||
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;
|
||||
@@ -82,7 +91,26 @@ export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds
|
||||
(cue) =>
|
||||
cue.source === 'canonical-ass' && cue.startTime <= timeSeconds && cue.endTime > timeSeconds,
|
||||
);
|
||||
const selectedCanonical = new Set<SubtitleCue>(authoredCanonical);
|
||||
const enteringCanonical = cues.filter(
|
||||
(cue) =>
|
||||
cue.source === 'canonical-ass' &&
|
||||
(cue.animationStartTime ?? cue.startTime) <= timeSeconds &&
|
||||
cue.startTime > timeSeconds &&
|
||||
(cue.animationEndTime ?? cue.endTime) > timeSeconds,
|
||||
);
|
||||
const nextAuthoredStart = enteringCanonical.reduce(
|
||||
(earliest, cue) => Math.min(earliest, cue.startTime),
|
||||
Infinity,
|
||||
);
|
||||
// Generated lyrics can begin drawing before their canonical Comment timing. Once that
|
||||
// entrance starts, replace a preceding lyric that ends before the new authored span;
|
||||
// genuinely concurrent subtitles that continue through the new span stay selected.
|
||||
const selectedCanonical = new Set<SubtitleCue>([
|
||||
...authoredCanonical.filter(
|
||||
(cue) => enteringCanonical.length === 0 || cue.endTime > nextAuthoredStart,
|
||||
),
|
||||
...enteringCanonical,
|
||||
]);
|
||||
if (selectedCanonical.size === 0) {
|
||||
const animatedCanonical = cues.filter(
|
||||
(cue) =>
|
||||
@@ -153,15 +181,17 @@ export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds
|
||||
activeCues.sort(compareAuthoredSubtitleOrder);
|
||||
|
||||
for (const { cue } of activeCues) {
|
||||
const text = cue.text.trim();
|
||||
const compactText = text.replace(/\s+/gu, '');
|
||||
if (!compactText || seenExact.has(compactText)) continue;
|
||||
seenExact.add(compactText);
|
||||
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);
|
||||
const flattenedIdentity = flattenedSecondarySubtitleLineIdentity(text);
|
||||
if (flattenedIdentity && seenFlattened.has(flattenedIdentity)) continue;
|
||||
if (flattenedIdentity) seenFlattened.add(flattenedIdentity);
|
||||
activeText.push(text);
|
||||
}
|
||||
}
|
||||
return activeText.join('\n');
|
||||
}
|
||||
@@ -182,6 +212,7 @@ export function createSecondarySubtitleTrackController(deps: {
|
||||
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;
|
||||
@@ -211,6 +242,7 @@ export function createSecondarySubtitleTrackController(deps: {
|
||||
const generation = ++refreshGeneration;
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected) {
|
||||
activeSourceUsesAssSyntax = false;
|
||||
useLiveFallback();
|
||||
return;
|
||||
}
|
||||
@@ -227,6 +259,7 @@ export function createSecondarySubtitleTrackController(deps: {
|
||||
|
||||
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw.trim() : '';
|
||||
if (!videoPath || secondarySid === null || secondarySid === 'no') {
|
||||
activeSourceUsesAssSyntax = false;
|
||||
useLiveFallback();
|
||||
return;
|
||||
}
|
||||
@@ -248,11 +281,14 @@ export function createSecondarySubtitleTrackController(deps: {
|
||||
});
|
||||
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()));
|
||||
@@ -274,6 +310,7 @@ export function createSecondarySubtitleTrackController(deps: {
|
||||
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 {
|
||||
@@ -296,6 +333,7 @@ export function createSecondarySubtitleTrackController(deps: {
|
||||
parsedCues = null;
|
||||
parsedSourceKey = null;
|
||||
parsedTrackIdentity = null;
|
||||
activeSourceUsesAssSyntax = false;
|
||||
secondaryDelaySeconds = 0;
|
||||
lastLiveText = '';
|
||||
publish('');
|
||||
@@ -305,7 +343,9 @@ export function createSecondarySubtitleTrackController(deps: {
|
||||
refresh,
|
||||
scheduleRefresh,
|
||||
handleLiveText(text: string): void {
|
||||
lastLiveText = text;
|
||||
lastLiveText = removeLiveGlyphFragmentLines(
|
||||
activeSourceUsesAssSyntax ? removeAssControlDebrisLines(text) : text,
|
||||
);
|
||||
publish(resolveAtTime(deps.getCurrentTimePos()));
|
||||
},
|
||||
handleTimePos(timeSeconds: number): void {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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:';
|
||||
@@ -126,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;
|
||||
}
|
||||
@@ -156,7 +159,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
} | null;
|
||||
getLastObservedTimePos: () => number;
|
||||
shouldKeepExistingCuesOnMissingSource?: (videoPath: string) => boolean;
|
||||
shouldKeepExistingCuesOnMissingSource?: (videoPath: string) => boolean | Promise<boolean>;
|
||||
subtitlePrefetchInitController: SubtitlePrefetchInitController;
|
||||
resolveActiveSubtitleSidebarSource: (
|
||||
input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0],
|
||||
@@ -195,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',
|
||||
);
|
||||
|
||||
@@ -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<string, string>;
|
||||
@@ -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'), []);
|
||||
});
|
||||
|
||||
@@ -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" =~/);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1424,11 +1424,8 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
|
||||
);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines preserves short stacks without layer metadata', () => {
|
||||
test('prepareSecondarySubtitleLines collapses exact short copies in stacks', () => {
|
||||
assert.deepEqual(prepareSecondarySubtitleLines('Your\\NYour\\NYour\\NYour\\Nmosaic'), [
|
||||
'Your',
|
||||
'Your',
|
||||
'Your',
|
||||
'Your',
|
||||
'mosaic',
|
||||
]);
|
||||
@@ -1438,6 +1435,15 @@ test('prepareSecondarySubtitleLines preserves short stacks without layer metadat
|
||||
]);
|
||||
});
|
||||
|
||||
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.
|
||||
@@ -1448,10 +1454,10 @@ test('prepareSecondarySubtitleLines collapses karaoke syllable spam into one ded
|
||||
assert.deepEqual(prepareSecondarySubtitleLines(karaoke), ['ya This no ma ups']);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines preserves repeated short dialogue without layer metadata', () => {
|
||||
test('prepareSecondarySubtitleLines collapses exact repeated short lines', () => {
|
||||
const dialogue = ['Wait', 'Wait', 'Wait'];
|
||||
|
||||
assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), dialogue);
|
||||
assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), ['Wait']);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines collapses punctuation variants of a full-sentence fallback', () => {
|
||||
@@ -1469,6 +1475,10 @@ test('prepareSecondarySubtitleLines preserves short simultaneous dialogue withou
|
||||
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. ';
|
||||
|
||||
@@ -1490,13 +1500,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', () => {
|
||||
|
||||
@@ -667,12 +667,17 @@ function isKaraokeLikeLineSet(lines: string[]): boolean {
|
||||
}
|
||||
|
||||
function collapseFullLineFallbackCopies(lines: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const seenExact = new Set<string>();
|
||||
const seenFlattened = new Set<string>();
|
||||
return lines.filter((line) => {
|
||||
const identity = flattenedSecondarySubtitleLineIdentity(line);
|
||||
if (!identity) return true;
|
||||
if (seen.has(identity)) return false;
|
||||
seen.add(identity);
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export type WorkflowStep = {
|
||||
name?: string;
|
||||
run?: string;
|
||||
env?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ParsedWorkflow = {
|
||||
jobs?: Record<string, { steps?: WorkflowStep[] } | undefined>;
|
||||
};
|
||||
|
||||
// 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 ?? '<unnamed>'}: ${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 ?? '<unnamed>'}`);
|
||||
}
|
||||
Reference in New Issue
Block a user