mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-11 17:16:18 -07:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a98fb0fddf
|
||
|
|
e816b3b371 |
@@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.4 (2026-08-25)
|
||||
|
||||
### Added
|
||||
- **Library Merge & Move**: Duplicate library cards for the same show can now be combined. Select cards in the library grid and use "Merge Selected" to pick which entry to keep and move every episode onto it, preserving sessions, mined cards, and watch time. Episodes can also be reassigned individually via the "→" button, useful when a file lands under a stray title; manual assignments survive later filename parsing, Jellyfin refreshes, and season repair. Exact AniList title matches with compatible seasons now merge automatically, while fuzzy matches surface as dismissible "Possible duplicate" reviews instead of merging silently.
|
||||
- **Duplicate Line Cleanup Tool**: The Vocabulary tab's new "Duplicates" button scans a chosen time window (7 days through all time) for old karaoke/typeset duplicate-line bursts, shows what it found, and collapses each run to one line once confirmed; `subminer stats cleanup --duplicate-lines` does the same from the terminal, with `--dry-run` and `--lookback-days <n>` options. Watch time and lines-seen totals are left unchanged.
|
||||
|
||||
### Changed
|
||||
- **Prerelease Release Notes**: Prerelease notes now open with a "Changes since" section listing only what changed versus the previous beta/RC of the same version, above the cumulative highlights, and CI rejects prerelease tags whose committed notes were generated for a different beta/RC.
|
||||
|
||||
### Fixed
|
||||
- **Subtitle & Karaoke Duplication**: Typeset ASS karaoke and animated signs no longer flood the overlay, subtitle sidebar, immersion history, mining, or stats with glyph fragments, per-frame color phases, or repeated animation events. Lines are reconstructed once from their authored text and shown only while actually sung, with original word spacing preserved. Decorative layers (highlight sweeps, glow/shadow copies, symbol-font decoration, particle swarms, hidden or zero-scaled text) stay out of published text, while ordinary repeated dialogue, positioned signs, wrapped lyric rows, and multi-row CC-style blocks still display correctly. Embedded subtitle tracks on network-mounted (SMB/NFS) media are extracted and parsed again instead of falling back to live-text-only, restoring karaoke reconstruction, sidebar cues, and mining for releases that only ship subtitles inside the container. Secondary subtitles now go through the same deduplication pipeline as primary subtitles and no longer clip display after about four lines. Event-heavy karaoke files that previously stalled subtitle loading for several seconds now parse in well under a second.
|
||||
- **Character Dictionary Reliability**: Character dictionary generation, merged rebuilds, and imports no longer freeze the app on large dictionaries. Snapshot I/O, archive building, and image/name lookup caches moved off the UI's critical path. Dictionaries are reused instead of regenerated when MeCab finds no name splits, and cached portraits now restore correctly after the portrait index finishes loading post-tokenization. Desktop progress notifications on Linux AppImage installs now update in place instead of flickering, fixing a bug where the AppImage's bundled libraries broke the system notification helper.
|
||||
- **Overlay Startup & Modals**: Fixed several causes of the overlay getting stuck on "Overlay loading": the macOS window-tracking helper now targets macOS 12.0+ instead of requiring the build machine's exact macOS version (previously crashed on older systems like Ventura), and mpv IPC connection attempts now time out and retry, showing an actionable error if content still isn't ready after 30 seconds. Dedicated overlay modals are also prewarmed on macOS and Windows so shortcuts open them promptly, and on macOS reused modals and the stats window now open above fullscreen mpv on its current Space instead of jumping to another desktop.
|
||||
- **Wayland File Drop**: Fixed native Wayland drag-and-drop from file managers such as Thunar, so subtitle and video files dropped on the visible overlay are resolved and forwarded to mpv.
|
||||
- **Windows Mouse Lag**: Fixed system-wide mouse lag on Windows while SubMiner is running, caused by the overlay's global mouse hook for click-through forwarding and by the mpv window tracker blocking the app on repeated PowerShell lookups.
|
||||
- **Sentence Mining Audio & Clips**: Sentence-audio generation no longer times out on slow network-mounted media with many subtitle/font streams (bounded FFmpeg probing, two-minute extraction budget, clearer error reporting), and mined audio/animated AVIF clips now capture the subtitle line that was actually mined by snapshotting the clip range at lookup time instead of reading live mpv state later.
|
||||
- **Stats Performance & Reliability**: Immersion stats storage now sets its SQLite busy timeout before WAL setup, avoiding transient lock errors under concurrent writes. Deletes in the stats dashboard no longer freeze the UI, run proportional to what's deleted instead of rebuilding full lifetime summaries, retry safely if the delete worker crashes, and no longer rescan the whole library when deleting very common words; a new index also makes large session deletes drop from minutes to milliseconds. Library merges, video moves, and AniList reassignments got the same lifetime-summary fix.
|
||||
- **Vocabulary Stats Accuracy**: Vocabulary totals and charts now count all tracked vocabulary instead of only the first page, new-word history uses corrected daily rollups (fixing legacy timestamp and time-zone issues), summary cards refresh automatically after edits to the exclusion list, and rapid exclusion edits no longer race each other.
|
||||
- **Rofi MKV Thumbnails**: Fixed missing MKV thumbnails in the Linux rofi picker when system thumbnailer registrations only advertise legacy Matroska MIME aliases.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- Docs Site Indexing: Excluded the `/main/` and `/v/<version>/` docs trees from search indexing (self-referential canonical, `noindex,follow`, matching `X-Robots-Tag`) so crawlers focus on current docs instead of ~30 archived copies of every page, and restored `<lastmod>` dates in the docs sitemap that were silently dropped by production builds.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.3 (2026-08-13)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: subtitles
|
||||
|
||||
- Typeset ASS karaoke and animated signs no longer flood the primary overlay, subtitle sidebar, immersion history, or sentence mining with repeated glyph fragments or full-line color phases. Matching timed comments and full-line boundary events recover the complete authored line without merging ordinary repeated dialogue or separately positioned signs, and dialogue spoken while a song's animation is on screen is kept intact instead of being replaced by the lyric. Entrance and exit frames that run past the authored line timing still resolve to the clean line during lyric transitions, and dialogue spoken while a song's animation is on screen enters immersion and subtitle history without the fragment lines beside it. Dense visual grids (sign walls, countdown frames, scattered glyph typesetting) stay out of the published text, while multi-row CC-style dialogue blocks and wrapped lyric rows are still published. Decorative letters that lyric effects render in symbol fonts over the syllables are dropped with the animation instead of corrupting the reconstructed line or leaking as stray cues. Karaoke highlight sweeps that repaint one syllable at a time over an already-visible lyric are suppressed instead of surfacing as rolling partial copies or lone flickering syllables beside the line, and drop-shadow glyph copies offset a few pixels from their base no longer double every syllable in the reconstructed lyric. Positioned word gaps are also recovered on lines where a single fragment carries a literal space, and between wide syllable chunks whose word gap is hidden by their own width, so reconstructed translations keep their spacing instead of running words together.
|
||||
- The secondary subtitle overlay drops layered duplicate lines from animated tracks, so a short stack of repeated words collapses to its distinct lines even when the full karaoke heuristic does not apply.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: Anki media
|
||||
|
||||
- Fixed sentence-audio generation timing out on slow network-mounted MKV files with many subtitle and font-attachment streams. Selected audio tracks now use bounded FFmpeg probing and a two-minute extraction budget, and missing output reports a clear FFmpeg error instead of raw `ENOENT`.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: character dictionary
|
||||
|
||||
- Reuse character dictionaries after MeCab completes without finding any name splits instead of regenerating character data and portraits on every launch.
|
||||
- Restore inline character portraits when a cached portrait index finishes loading after subtitles have already been tokenized.
|
||||
@@ -1,5 +0,0 @@
|
||||
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.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: dictionary
|
||||
|
||||
- Character dictionary generation, merged rebuilds, and imports no longer freeze the app (and trigger the compositor's "application not responding" dialog) on large dictionaries; snapshot reads/writes, archive building, and the character image/name lookup caches now do their heavy work off the UI's critical path.
|
||||
- Desktop progress notifications now update in place on Linux AppImage installs too: the AppImage's bundled libraries broke the system notify-send helper, which silently forced the flickering close-and-reopen notification fallback.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: internal
|
||||
area: docs
|
||||
|
||||
- Excluded the `/main/` and `/v/<version>/` docs trees from search indexing with a self-referential canonical, `noindex,follow`, and a matching `X-Robots-Tag` header, so crawlers spend their budget on the current docs instead of ~30 archived copies of every page.
|
||||
- Restored `<lastmod>` dates in the docs sitemap, which were silently dropped because production builds render from an untracked release snapshot.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Typeset subtitles no longer flood the stats. Karaoke openings and animated signs are authored as one subtitle event per animation frame, and immersion tracking counted every frame, which was enough to put an OP lyric at the top of "Top Repeated Words" for good. Lines are now collapsed on the way in using the same rules the subtitle sidebar already applies: matching parsed timings record exactly the cues the sidebar shows, while shifted, changing, or unparsed sources use a strict fallback where identical, contiguous, sub-0.1s lines stop counting after a few frames. Ordinary repeated dialogue and rewatches are unaffected.
|
||||
- Added a cleanup for stats already affected. The Vocabulary tab has a **Duplicates** button that scans a chosen window (7 days through all time), shows the bursts it found and the word and kanji counts they added, and collapses each run to one line once confirmed. `subminer stats cleanup --duplicate-lines` does the same from the terminal, with `--dry-run` and `--lookback-days <n>`. Only subtitle lines and the vocabulary counts they feed are touched; watch time and lines-seen totals are left as recorded.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Dedicated overlay modals are prewarmed on macOS and Windows so shortcuts open them promptly on the first press. Windows now refreshes the hidden modal renderer between sessions to keep later modals interactive. On macOS, reused modals and the in-app stats window also open above fullscreen mpv on its current Space instead of appearing on another desktop or forcing a Space change.
|
||||
- Updated subtitle ASS observation to mpv's current `sub-text/ass` property, removing its deprecation warning.
|
||||
@@ -1,4 +0,0 @@
|
||||
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 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed the overlay getting stuck on "Overlay loading" forever when startup stalls: mpv IPC connection attempts now time out and retry, switching sockets aborts obsolete attempts, and the plugin replaces its spinner with an actionable error if overlay content is still not ready after 30 seconds.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed native Wayland drag-and-drop from file managers such as Thunar so subtitle and video files dropped on the visible overlay are resolved and forwarded to mpv.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: subtitles
|
||||
|
||||
- Primary and secondary ASS subtitles now collapse layered and whitespace variants of full-span lyrics, including when playback starts or seeks into a line, reconstruct fragment-only karaoke per style, preserve authored stack order, keep canonical signs visible for their complete generated animation, navigate song lyrics by sanitized lines instead of generated animation events, and keep sidebar selections on the requested overlapping lyric while preserving unmatched dialogue and signs.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Fragmented ASS karaoke keeps spaces authored at event boundaries and recovers Latin word spaces encoded only by positioned fragment gaps, including word gaps measured across wide glyphs that width normalization alone reads as ordinary letter advances. Progressive karaoke highlights, offset shadow copies, 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. Static canonical lyric lines also replace their animated glyph copies. Wrapped lyrics remain intact when a timed token repeats at another horizontal position. Tiny multiline alpha payloads from known texture-font families are suppressed, while phone translations styled with word-level secondary alpha remain publishable. 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.
|
||||
@@ -1,4 +0,0 @@
|
||||
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.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed system-wide mouse lag on Windows while SubMiner is running: the overlay no longer installs Electron's global mouse hook for click-through forwarding, and the mpv window tracker no longer blocks the app on repeated PowerShell command-line lookups.
|
||||
@@ -1,6 +0,0 @@
|
||||
type: added
|
||||
area: stats
|
||||
|
||||
- Library: duplicate cards for the same show can now be combined. Press "Select" above the library grid, tick the cards, and use "Merge Selected"; the dialog picks which entry to keep and moves every episode onto it. Sessions, mined cards, and watch time are preserved, the emptied entries disappear, and remembered title aliases keep future episodes on the merged card.
|
||||
- Library: episodes can be reassigned to another library entry from the "→" button on an episode row, which is the fix when one file lands under a stray title (e.g. an episode name parsed as the series). Manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair. Local episodes in the same directory reuse a uniquely corrected destination unless they parse to a title that already has its own library entry, while conflicting seasons or manual destinations are not forced together. Emptying an entry this way removes it and returns to the grid.
|
||||
- Library: exact AniList title matches with compatible seasons fold duplicate cards automatically. Fuzzy same-AniList matches appear as dismissible "Possible duplicate" reviews instead of changing the library without confirmation; conflicting explicit seasons are left alone.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: notifications
|
||||
|
||||
- Character dictionary progress notifications on Linux now update in place instead of flickering off and reappearing on every status change.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Mined audio and animated AVIF clips now capture the subtitle line that was actually mined. The clip range is snapshotted once at Yomitan lookup time (and reused for both audio and image), instead of each generator reading the live mpv subtitle when it starts — which clipped whatever line was on screen after slow audio extraction finished, producing too-short or misaligned AVIF clips.
|
||||
@@ -1,5 +0,0 @@
|
||||
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.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Secondary subtitle overlays now show every rendered line instead of clipping text after roughly four lines.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: launcher
|
||||
|
||||
- Fixed missing MKV thumbnails in the Linux rofi picker when system thumbnailer registrations only advertise legacy Matroska MIME aliases.
|
||||
@@ -1,9 +0,0 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Stats deletes no longer freeze the stats dashboard: the delete worker module now resolves when running from source, so deletes actually run off the serving thread instead of silently falling back to it.
|
||||
- Deletes now subtract their exact contribution from lifetime summaries instead of rebuilding them from retained sessions, making delete cost proportional to what is deleted and preserving lifetime totals older than the session retention window.
|
||||
- If the delete worker crashes, the delete now retries on the current thread instead of failing.
|
||||
- Library merges, video moves, AniList reassignments, and `subminer stats cleanup -l` also stopped rebuilding lifetime summaries from retained sessions; they now recompute from per-episode history, so those operations are faster and no longer erase lifetime totals older than the session retention window.
|
||||
- Deleting content that contains very common words no longer rescans every occurrence of those words across the whole library; first/last-seen dates are refreshed with index seeks instead.
|
||||
- Session deletes on large databases dropped from minutes to milliseconds: an index on the subtitle-line event reference now prevents each deleted session event from scanning the whole subtitle-line table for foreign-key enforcement.
|
||||
@@ -1,8 +0,0 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Fixed Vocabulary totals and charts counting only the first browsing page instead of all tracked vocabulary, without delaying the rest of the page.
|
||||
- New-word history now uses permanent daily lexical rollups that apply the same vocabulary filters as the totals and normalize legacy second/millisecond timestamps; versioned background rebuilds repair existing history across legacy rollup-state schemas without dropping playback writes or clearing watch-time, activity, efficiency, and library charts.
|
||||
- Calendar-day chart labels now preserve the recorded local date in time zones west of UTC.
|
||||
- Vocabulary summary cards and charts refresh automatically after the word exclusion list changes, and failed or unfinished loads use bounded retries before showing an inline error with a Retry control.
|
||||
- Rapid exclusion edits no longer race each other; writes are sent in order so a slower earlier save cannot overwrite a newer list.
|
||||
@@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.4 (2026-08-25)
|
||||
|
||||
**Added**
|
||||
- **Library Merge & Move**: Duplicate library cards for the same show can now be combined. Select cards in the library grid and use "Merge Selected" to pick which entry to keep and move every episode onto it, preserving sessions, mined cards, and watch time. Episodes can also be reassigned individually via the "→" button, useful when a file lands under a stray title; manual assignments survive later filename parsing, Jellyfin refreshes, and season repair. Exact AniList title matches with compatible seasons now merge automatically, while fuzzy matches surface as dismissible "Possible duplicate" reviews instead of merging silently.
|
||||
- **Duplicate Line Cleanup Tool**: The Vocabulary tab's new "Duplicates" button scans a chosen time window (7 days through all time) for old karaoke/typeset duplicate-line bursts, shows what it found, and collapses each run to one line once confirmed; `subminer stats cleanup --duplicate-lines` does the same from the terminal, with `--dry-run` and `--lookback-days <n>` options. Watch time and lines-seen totals are left unchanged.
|
||||
|
||||
**Changed**
|
||||
- **Prerelease Release Notes**: Prerelease notes now open with a "Changes since" section listing only what changed versus the previous beta/RC of the same version, above the cumulative highlights, and CI rejects prerelease tags whose committed notes were generated for a different beta/RC.
|
||||
|
||||
**Fixed**
|
||||
- **Subtitle & Karaoke Duplication**: Typeset ASS karaoke and animated signs no longer flood the overlay, subtitle sidebar, immersion history, mining, or stats with glyph fragments, per-frame color phases, or repeated animation events. Lines are reconstructed once from their authored text and shown only while actually sung, with original word spacing preserved. Decorative layers (highlight sweeps, glow/shadow copies, symbol-font decoration, particle swarms, hidden or zero-scaled text) stay out of published text, while ordinary repeated dialogue, positioned signs, wrapped lyric rows, and multi-row CC-style blocks still display correctly. Embedded subtitle tracks on network-mounted (SMB/NFS) media are extracted and parsed again instead of falling back to live-text-only, restoring karaoke reconstruction, sidebar cues, and mining for releases that only ship subtitles inside the container. Secondary subtitles now go through the same deduplication pipeline as primary subtitles and no longer clip display after about four lines. Event-heavy karaoke files that previously stalled subtitle loading for several seconds now parse in well under a second.
|
||||
- **Character Dictionary Reliability**: Character dictionary generation, merged rebuilds, and imports no longer freeze the app on large dictionaries. Snapshot I/O, archive building, and image/name lookup caches moved off the UI's critical path. Dictionaries are reused instead of regenerated when MeCab finds no name splits, and cached portraits now restore correctly after the portrait index finishes loading post-tokenization. Desktop progress notifications on Linux AppImage installs now update in place instead of flickering, fixing a bug where the AppImage's bundled libraries broke the system notification helper.
|
||||
- **Overlay Startup & Modals**: Fixed several causes of the overlay getting stuck on "Overlay loading": the macOS window-tracking helper now targets macOS 12.0+ instead of requiring the build machine's exact macOS version (previously crashed on older systems like Ventura), and mpv IPC connection attempts now time out and retry, showing an actionable error if content still isn't ready after 30 seconds. Dedicated overlay modals are also prewarmed on macOS and Windows so shortcuts open them promptly, and on macOS reused modals and the stats window now open above fullscreen mpv on its current Space instead of jumping to another desktop.
|
||||
- **Wayland File Drop**: Fixed native Wayland drag-and-drop from file managers such as Thunar, so subtitle and video files dropped on the visible overlay are resolved and forwarded to mpv.
|
||||
- **Windows Mouse Lag**: Fixed system-wide mouse lag on Windows while SubMiner is running, caused by the overlay's global mouse hook for click-through forwarding and by the mpv window tracker blocking the app on repeated PowerShell lookups.
|
||||
- **Sentence Mining Audio & Clips**: Sentence-audio generation no longer times out on slow network-mounted media with many subtitle/font streams (bounded FFmpeg probing, two-minute extraction budget, clearer error reporting), and mined audio/animated AVIF clips now capture the subtitle line that was actually mined by snapshotting the clip range at lookup time instead of reading live mpv state later.
|
||||
- **Stats Performance & Reliability**: Immersion stats storage now sets its SQLite busy timeout before WAL setup, avoiding transient lock errors under concurrent writes. Deletes in the stats dashboard no longer freeze the UI, run proportional to what's deleted instead of rebuilding full lifetime summaries, retry safely if the delete worker crashes, and no longer rescan the whole library when deleting very common words; a new index also makes large session deletes drop from minutes to milliseconds. Library merges, video moves, and AniList reassignments got the same lifetime-summary fix.
|
||||
- **Vocabulary Stats Accuracy**: Vocabulary totals and charts now count all tracked vocabulary instead of only the first page, new-word history uses corrected daily rollups (fixing legacy timestamp and time-zone issues), summary cards refresh automatically after edits to the exclusion list, and rapid exclusion edits no longer race each other.
|
||||
- **Rofi MKV Thumbnails**: Fixed missing MKV thumbnails in the Linux rofi picker when system thumbnailer registrations only advertise legacy Matroska MIME aliases.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- Docs Site Indexing: Excluded the `/main/` and `/v/<version>/` docs trees from search indexing (self-referential canonical, `noindex,follow`, matching `X-Robots-Tag`) so crawlers focus on current docs instead of ~30 archived copies of every page, and restored `<lastmod>` dates in the docs sitemap that were silently dropped by production builds.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.3 (2026-08-13)
|
||||
|
||||
**Added**
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.19.4-beta.4",
|
||||
"version": "0.19.4",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
## Highlights
|
||||
### Added
|
||||
- **Library Duplicate & Misfiled Episode Tools**
|
||||
- Merge duplicate show cards from the Library grid: select cards and use "Merge Selected" to combine sessions, mined cards, and watch time onto one entry while keeping remembered title aliases.
|
||||
- Reassign a misfiled episode to the correct show with the "→" button on an episode row; the fix survives later filename parsing, Jellyfin refreshes, and season repair.
|
||||
- Exact AniList matches merge automatically, while likely (fuzzy) matches surface as a dismissible "Possible duplicate" suggestion instead of merging without confirmation.
|
||||
- **Stats Duplicate-Line Cleanup Tool**
|
||||
- The Vocabulary tab's new Duplicates button scans a chosen time window for old karaoke/animation duplicate bursts and collapses each one to a single line after you confirm, without touching watch time or lines-seen totals.
|
||||
- The same cleanup is available from the terminal via `subminer stats cleanup --duplicate-lines`, with `--dry-run` and `--lookback-days` options.
|
||||
|
||||
### Changed
|
||||
- **Prerelease Notes "Changes Since" Section**
|
||||
- Prerelease release notes now open with a "Changes since" section listing only what changed since the previous beta/RC of the same version, shown above the full cumulative highlights.
|
||||
|
||||
### Fixed
|
||||
- **Subtitle Deduplication & Karaoke Reconstruction**
|
||||
- Typeset ASS karaoke and animated signs are reconstructed into their authored line and shown once, instead of flooding the overlay, subtitle sidebar, immersion history, sentence mining, and stats with per-frame glyph fragments and repeated lyric bursts (a lyric could previously pin itself to the top of "Top Repeated Words").
|
||||
- The same deduplication now applies consistently everywhere, including embedded subtitles extracted from network-mounted (SMB/NFS) media and the secondary subtitle overlay, while ordinary repeated dialogue, signs, and rewatches remain unaffected.
|
||||
- Secondary subtitle overlays no longer clip long lines after about four rows, and no longer show scattered-letter or duplicated text while embedded subtitles are still being extracted.
|
||||
- **Character Dictionary Reliability & Notifications**
|
||||
- Character dictionary generation, rebuilds, and imports no longer freeze the app or trigger "not responding" dialogs on large dictionaries; the heavy work now runs off the main UI thread.
|
||||
- Dictionaries are reused instead of being regenerated on every launch when no name splits were found, and portraits reappear correctly once the cached portrait index finishes loading.
|
||||
- Linux desktop progress notifications, including on AppImage installs, now update in place instead of flickering closed and reopening.
|
||||
- **Overlay Startup Reliability**
|
||||
- The overlay no longer gets stuck on an endless "Overlay loading" screen when mpv's connection stalls at startup; connections now time out and retry, and a clear error appears if content still isn't ready after 30 seconds.
|
||||
- **Overlay Modal Windows (macOS & Windows)**
|
||||
- Modal windows such as Settings prewarm so shortcuts open them promptly on first press.
|
||||
- On Windows, the hidden modal renderer now refreshes between sessions so later modals stay interactive.
|
||||
- On macOS, reused modals and the stats window open above fullscreen mpv on the correct Space instead of jumping to another desktop; the overlay-attach helper also now supports macOS 12.0+, fixing "Overlay loading" getting stuck on older macOS versions.
|
||||
- **Windows Mouse Lag**
|
||||
- Fixed system-wide mouse lag while SubMiner is running: the overlay no longer installs a global mouse hook, and the mpv window tracker no longer blocks the app with repeated command-line lookups.
|
||||
- **Linux Overlay & Launcher Fixes**
|
||||
- Native Wayland drag-and-drop from file managers such as Thunar now works, so subtitle and video files dropped on the overlay reach mpv.
|
||||
- Fixed missing MKV thumbnails in the rofi file picker on systems that only advertise legacy Matroska MIME aliases.
|
||||
- **Sentence Mining Audio & Clip Accuracy**
|
||||
- Sentence-audio generation no longer times out on slow network-mounted MKV files with many subtitle/font streams; probing is now bounded with a two-minute extraction budget and a clear error instead of a raw failure.
|
||||
- Mined audio and animated clips now capture the exact subtitle line that was mined, instead of whatever line was on screen after audio extraction finished, fixing too-short or misaligned clips.
|
||||
- **Stats Reliability & Performance**
|
||||
- Fixed transient database-lock errors when multiple stats workers wrote at once.
|
||||
- Stats deletes, library merges, video moves, and AniList reassignments no longer freeze the dashboard or rebuild lifetime totals from scratch, so they're fast and preserve lifetime totals older than the recent session-retention window; session deletes on large databases dropped from minutes to milliseconds.
|
||||
- **Vocabulary Tab Accuracy**
|
||||
- Vocabulary totals and charts now count all tracked vocabulary instead of only the first page, with new-word history rebuilt from corrected daily rollups to match.
|
||||
- Calendar charts keep the correct local date in time zones west of UTC, and vocabulary cards/charts now refresh automatically and retry after the word exclusion list changes.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(stats): add library entry merge and episode move by @ksyasuda in #190
|
||||
- fix(stats): stop counting duplicate typeset subtitle lines by @ksyasuda in #191
|
||||
- fix(media): tolerate slow MKV audio extraction by @ksyasuda in #195
|
||||
- fix(stats): subtract lifetime totals incrementally on delete by @ksyasuda in #196
|
||||
- fix(anki): snapshot mining media clip timing by @ksyasuda in #197
|
||||
- fix(notifications): replace Linux progress updates in place by @ksyasuda in #198
|
||||
- fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199
|
||||
- fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200
|
||||
- fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201
|
||||
- fix(stats): report complete vocabulary totals and new-word history by @ksyasuda in #202
|
||||
- fix(mpv): recover from stalled IPC connects by @ksyasuda in #204
|
||||
- fix(dictionary): prevent freezes and restore AppImage notifications by @ksyasuda in #205
|
||||
- fix(subtitles): recover canonical lines from ASS animation by @ksyasuda in #207
|
||||
- fix(overlay): deduplicate secondary subtitle rendering by @ksyasuda in #208
|
||||
- fix(launcher): restore Matroska thumbnails in Linux rofi picker by @ksyasuda in #210
|
||||
- fix(character-dictionary): cache completed MeCab refreshes by @ksyasuda in #212
|
||||
- fix(subtitles): improve secondary subtitle extraction and display by @ksyasuda in #215
|
||||
- feat(release): track prerelease deltas and validate committed notes by @ksyasuda in #216
|
||||
- fix(subtitles): recover positioned ASS word spacing and drop control debris by @ksyasuda in #217
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
@@ -1307,7 +1307,7 @@ test('parseSubtitleCues drops tiny alpha payloads from a proven texture font', (
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Comment: 2,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain Medium\\fs100\\alpha&HFC&}texture seed',
|
||||
'Dialogue: 2,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain Medium\\clip(500,40,660,150)}LLLLLLLLLLLL',
|
||||
"Dialogue: 1,0:00:06.00,0:00:09.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain\\fs10\\alpha&H70&}q26D'vrA;\\NE? GS\\NESLhlawEv",
|
||||
'Dialogue: 3,0:00:06.00,0:00:09.00,FrogSigns,,0,0,0,,{\\pos(1040,620)\\fnSF Pro Display\\fs66}Waiting!',
|
||||
].join('\n');
|
||||
@@ -1318,6 +1318,20 @@ test('parseSubtitleCues drops tiny alpha payloads from a proven texture font', (
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues preserves a small multiline translation using an unverified transparent font', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 2,0:00:01.00,0:00:04.00,Transition,,0,0,0,,{\\pos(580,95)\\fnPhone UI\\fs60\\alpha&HF0&}Faded transition',
|
||||
'Dialogue: 3,0:00:06.00,0:00:09.00,Phone,,0,0,0,,{\\pos(1040,620)\\fnPhone UI\\fs10\\alpha&H70&}Call me when you arrive.\\NI will still be awake.\\NDo not rush.',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
|
||||
['Faded transition', 'Call me when you arrive.\nI will still be awake.\nDo not rush.'],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues drops clipped repeated-glyph texture text without a font override', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
@@ -1829,3 +1843,157 @@ test('parseSubtitleCues splits chunked words whose gap only the excess rule catc
|
||||
'Choices presumably made by chance',
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues rebuilds a line from per-glyph phase stacks with staggered timing', () => {
|
||||
// Each glyph lives as four events anchored at one point: a transparent pre-echo until
|
||||
// its syllable is sung, a short highlight, a rising exit ghost, and a steady hold.
|
||||
// No timing window is shared across the phases, only the anchor ties them together.
|
||||
const glyphs = [
|
||||
['エ', 559],
|
||||
['ネ', 601],
|
||||
['ル', 643],
|
||||
['ギ', 686],
|
||||
['ー', 728],
|
||||
] as const;
|
||||
const timestamp = (seconds: number) => `0:00:${seconds.toFixed(2).padStart(5, '0')}`;
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
...glyphs.flatMap(([glyph, x], index) => {
|
||||
const highlightStart = 20.9 + index * 0.4;
|
||||
return [
|
||||
`Dialogue: 3,${timestamp(20 + index * 0.03)},${timestamp(highlightStart)},OP - JP,,0,0,0,,{\\blur1.5\\bord2\\c&H404040&\\3c&HFFFFFF&\\an5\\pos(${x},50)\\fad(300,0)\\1a&HFF&}${glyph}`,
|
||||
`Dialogue: 3,${timestamp(highlightStart)},${timestamp(highlightStart + 0.4)},OP - JP,,0,0,0,,{\\an5\\pos(${x},50)\\bord2\\c&H404040&\\3c&HFFFFFF&\\t(120,240,\\3c&H007A7A7A&\\blur0)\\fad(0,300)}${glyph}`,
|
||||
`Dialogue: 3,${timestamp(highlightStart)},${timestamp(highlightStart + 2.4)},OP - JP,,0,0,0,,{\\an5\\move(${x},50,${x},0)\\bord0\\shad0\\t(\\c&HFFFFFF&\\blur5\\alpha&HFF&)}${glyph}`,
|
||||
`Dialogue: 3,${timestamp(highlightStart + 0.4)},${timestamp(26.1 + index * 0.05)},OP - JP,,0,0,0,,{\\an5\\pos(${x},50)\\bord2\\c&H404040&\\3c&HFFFFFF&\\fad(0,300)}${glyph}`,
|
||||
];
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
assert.equal(cues.length, 1);
|
||||
assert.equal(cues[0]?.text, 'エネルギー');
|
||||
assert.equal(cues[0]?.source, 'reconstructed-ass');
|
||||
// Published from the first sung syllable to the end of the hold, so the transparent
|
||||
// lead-in and the exit ghosts' fade tail never overlap the neighboring lines. The
|
||||
// full generated span stays available as the animation window.
|
||||
assert.equal(cues[0]?.startTime, 20.9);
|
||||
assert.equal(cues[0]?.endTime, 26.3);
|
||||
assert.equal(cues[0]?.animationStartTime, 20);
|
||||
assert.equal(cues[0]?.animationEndTime, 26.3);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues drops transparent glow echoes and merges an exit replay', () => {
|
||||
// The visible line sits at one row while transparent-fill glow copies duplicate every
|
||||
// glyph on another row, and the exit shatters each glyph into copies launched from a
|
||||
// shared anchor. Only the authored line may publish, as a single unbroken cue.
|
||||
const glyphs = [
|
||||
['さ', 686],
|
||||
['あ', 728],
|
||||
['預', 770],
|
||||
['け', 812],
|
||||
['て', 854],
|
||||
] as const;
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
...glyphs.flatMap(([glyph, x]) => [
|
||||
`Dialogue: 3,0:00:16.28,0:00:18.71,OP - JP,,0,0,0,,{\\an2\\pos(${x},85)\\fad(200,0)\\fry-90\\c&H404040&\\3c&HF4F4F4&\\bord2\\t(0,300,\\fry0)}${glyph}`,
|
||||
...[0, 1].map(
|
||||
() =>
|
||||
`Dialogue: 3,0:00:16.28,0:00:20.01,OP - JP,,0,0,0,,{\\pos(${x},15)\\blur5.8\\fry-90\\1a&HFF&\\fad(200,0)\\3c&H3F26AA&\\t(0,300,\\fry0)\\t(2596,3222,\\bord0\\3a&HFF&)}${glyph}`,
|
||||
),
|
||||
...[0, 1].map(
|
||||
(copy) =>
|
||||
`Dialogue: 3,0:00:18.71,0:00:20.89,OP - JP,,0,0,0,,{\\an5\\bord2\\fad(0,200)\\move(${x},50,${x + 25 + copy * 3},${17 - copy * 39},1605,2055)\\t(450,792,\\c&H3500DE&\\bord0)\\t(1605,2055,\\blur15\\fscx20\\fscy20\\1a&H50&\\3a&H50&)}${glyph}`,
|
||||
),
|
||||
]),
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
assert.equal(cues.length, 1);
|
||||
assert.equal(cues[0]?.text, 'さあ預けて');
|
||||
assert.equal(cues[0]?.startTime, 16.28);
|
||||
assert.equal(cues[0]?.endTime, 20.89);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues does not double a line rendered whole beside its glyph swarm', () => {
|
||||
// An assembly effect shows the authored line as one positioned event while dozens of
|
||||
// per-glyph particle copies converge onto each glyph's anchor. The whole event and
|
||||
// the swarm spell the same text and must publish as one line, once.
|
||||
const glyphs = [
|
||||
['可', 854],
|
||||
['笑', 896],
|
||||
['し', 938],
|
||||
['い', 980],
|
||||
['わ', 1022],
|
||||
['ね', 1064],
|
||||
] as const;
|
||||
const wholeLine = glyphs
|
||||
.map(([glyph]) => `{\\an5\\fad(300,500)\\pos(960,50)}${glyph}`)
|
||||
.join('');
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
`Dialogue: 1,0:00:17.29,0:00:18.99,OP - JP,,0,0,0,,${wholeLine}`,
|
||||
...glyphs.flatMap(([glyph, x], index) =>
|
||||
[0, 1, 2].map(
|
||||
(copy) =>
|
||||
`Dialogue: 2,0:00:17.${30 + index * 5 + copy},0:00:19.10,OP - JP,,0,0,0,,{\\bord4\\blur4\\an5\\fad(500,0)\\move(${x - 60 - copy * 17},${120 + copy * 6},${x},50,20,900)\\clip(${x - 70},80,${x - 66},84)\\t(20,900,\\clip(${x - 4},7,${x},11))}${glyph}`,
|
||||
),
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
assert.equal(cues.length, 1);
|
||||
assert.equal(cues[0]?.text, '可笑しいわね');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues drops a wall of near-invisible positioned texture strings', () => {
|
||||
// An image drawn by \p1 vector events carries no texture seed, but its glyph payload
|
||||
// is still dozens of near-transparent positioned strings sharing one window. A real
|
||||
// faint translation is one or two events and stays published.
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
'Dialogue: 90,0:00:12.66,0:00:14.91,Default,,0,0,0,,We\'ll play as a band, and then...',
|
||||
...Array.from(
|
||||
{ length: 12 },
|
||||
(_, index) =>
|
||||
`Dialogue: 9,0:00:12.66,0:00:14.91,MarySigns,,0,0,0,,{\\an7\\pos(${640 + index * 13},${4 + index * 40})\\fnGrain SemiBold\\c&H000000&\\alpha&HFD&}gtO${index}x!`,
|
||||
),
|
||||
'Dialogue: 9,0:00:12.66,0:00:14.91,OtherSign,,0,0,0,,{\\pos(151,769)\\fnGrain\\alpha&HE0&}A faint but real translation',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
|
||||
["We'll play as a band, and then...", 'A faint but real translation'],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues drops zero-scaled zero-clipped hidden warning text', () => {
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
'Dialogue: 99,0:00:00.00,0:00:15.16,Default,,0,0,0,,{\\org(0,0)\\fscx0\\fscy0\\clip(0,0,0,0)}Your media player does not support the subtitle format.',
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\fscx0\\t(0,300,\\fscx100)}見えるセリフ',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
|
||||
['見えるセリフ'],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps hidden events hidden when a transform animates an unrelated tag', () => {
|
||||
// `\t(...)` only reveals a zero-scaled or fully clipped event when it animates the
|
||||
// scale or the clip itself. Animating an unrelated property -- at any nesting depth --
|
||||
// leaves the event invisible, so its text must not reach the subtitles.
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\fscx0\\fscy0\\clip(0,0,0,0)\\t(0,300,\\bord5)}hidden warning',
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\clip(0,0,0,0)\\t(0,600,\\t(0,300,\\blur4))}nested hidden warning',
|
||||
'Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,{\\fscx0\\t(0,300,\\fscx100)}grows into view',
|
||||
'Dialogue: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,{\\clip(0,0,0,0)\\t(0,300,\\clip(0,0,500,500))}wipes into view',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
|
||||
['grows into view', 'wipes into view'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -325,8 +325,17 @@ interface FragmentGroup {
|
||||
events: AnnotatedSubtitleCue[];
|
||||
}
|
||||
|
||||
// Anchor extraction walks every override command, and the canonical/fragment passes
|
||||
// consult the same events once per candidate neighborhood, so heavy KFX tags make the
|
||||
// uncached form quadratic in practice.
|
||||
const placementAnchorCache = new WeakMap<AnnotatedSubtitleCue, Set<string>>();
|
||||
|
||||
function fragmentPlacementAnchors(event: AnnotatedSubtitleCue): Set<string> {
|
||||
const anchors = new Set<string>();
|
||||
let anchors = placementAnchorCache.get(event);
|
||||
if (anchors) {
|
||||
return anchors;
|
||||
}
|
||||
anchors = new Set<string>();
|
||||
for (const command of event.overrides) {
|
||||
const name = command.name.toLowerCase();
|
||||
const args = command.args.split(',').map((value) => value.trim());
|
||||
@@ -337,6 +346,7 @@ function fragmentPlacementAnchors(event: AnnotatedSubtitleCue): Set<string> {
|
||||
anchors.add(`move:${args[2]},${args[3]}`);
|
||||
}
|
||||
}
|
||||
placementAnchorCache.set(event, anchors);
|
||||
return anchors;
|
||||
}
|
||||
|
||||
@@ -344,11 +354,52 @@ function fragmentPlacementAnchors(event: AnnotatedSubtitleCue): Set<string> {
|
||||
// kerned repeated glyphs in one line ("ii") measure 10px apart or more.
|
||||
const LAYER_COPY_OFFSET_TOLERANCE_PX = 6;
|
||||
|
||||
// Copies chain only through genuine time overlap. Consecutive re-runs of one visual
|
||||
// (chant bursts, countdown frames, jitter animation frames) abut or micro-overlap at
|
||||
// frame seams, so the different-structure threshold sits above a frame seam while the
|
||||
// phases of one effect (a highlight and the exit ghost it launches) overlap far longer.
|
||||
const MIN_PHASE_OVERLAP_SECONDS = 0.04;
|
||||
|
||||
// Decoration is timed to the line it accompanies, but a lead-in echo can end exactly
|
||||
// where the recovered line's first sung copy begins; a small tolerance keeps such
|
||||
// flush decoration attached to its line.
|
||||
const DECORATION_SPAN_TOLERANCE_SECONDS = 0.1;
|
||||
|
||||
// Guards for pathological event volumes. Real copy stacks and repaint chains stay in
|
||||
// the hundreds; a same-text bucket or candidate sweep group in the thousands is a
|
||||
// particle field, and the quadratic passes over it would stall the main process.
|
||||
const MAX_COALESCE_BUCKET_EVENTS = 1500;
|
||||
const MAX_SWEEP_GROUP_EVENTS = 4000;
|
||||
// Fragment-layer collapsing repeatedly rescans the remaining parts after each match.
|
||||
// Genuine authored lines stay far below this limit; larger groups are particle fields.
|
||||
const MAX_FRAGMENT_COLLAPSE_PARTS = 1500;
|
||||
|
||||
/**
|
||||
* Sources behind a coalesced copy chain. Synthetic cues stand in for their sources
|
||||
* during fragment recovery, but suppression and copy-count evidence must reach the
|
||||
* original events, which are what the published dialogue list still holds.
|
||||
*/
|
||||
const coalescedSourceEvents = new WeakMap<AnnotatedSubtitleCue, AnnotatedSubtitleCue[]>();
|
||||
|
||||
function sourceEventsOf(cue: AnnotatedSubtitleCue): readonly AnnotatedSubtitleCue[] {
|
||||
return coalescedSourceEvents.get(cue) ?? [cue];
|
||||
}
|
||||
|
||||
function sourceEventCount(events: readonly AnnotatedSubtitleCue[]): number {
|
||||
return events.reduce((count, event) => count + sourceEventsOf(event).length, 0);
|
||||
}
|
||||
|
||||
// One representative point per placement command: the `\pos` point or the `\move`
|
||||
// midpoint. Comparing raw `\move` endpoints cross-wise misreads a travel distance that
|
||||
// matches the glyph advance as a layer copy of a neighboring same-letter glyph.
|
||||
const anchorPointCache = new WeakMap<AnnotatedSubtitleCue, AssFragmentPosition[]>();
|
||||
|
||||
function fragmentAnchorPoints(event: AnnotatedSubtitleCue): AssFragmentPosition[] {
|
||||
const points: AssFragmentPosition[] = [];
|
||||
let points = anchorPointCache.get(event);
|
||||
if (points) {
|
||||
return points;
|
||||
}
|
||||
points = [];
|
||||
for (const command of event.overrides) {
|
||||
const name = command.name.toLowerCase();
|
||||
const args = command.args.split(',').map((value) => Number(value.trim()));
|
||||
@@ -358,6 +409,7 @@ function fragmentAnchorPoints(event: AnnotatedSubtitleCue): AssFragmentPosition[
|
||||
points.push({ x: (args[0]! + args[2]!) / 2, y: (args[1]! + args[3]!) / 2 });
|
||||
}
|
||||
}
|
||||
anchorPointCache.set(event, points);
|
||||
return points;
|
||||
}
|
||||
|
||||
@@ -387,8 +439,207 @@ function isRepeatedFragmentCopy(
|
||||
);
|
||||
}
|
||||
|
||||
// Coalescing keys on where a copy is anchored: the `\pos` point and both `\move`
|
||||
// endpoints, since exit ghosts launch from the glyph anchor and entrance copies
|
||||
// converge onto it.
|
||||
function coalesceAnchorPoints(event: AnnotatedSubtitleCue): AssFragmentPosition[] {
|
||||
const points: AssFragmentPosition[] = [];
|
||||
for (const command of event.overrides) {
|
||||
const name = command.name.toLowerCase();
|
||||
const args = command.args.split(',').map((value) => Number(value.trim()));
|
||||
if (name === 'pos' && args.length >= 2 && args.slice(0, 2).every(Number.isFinite)) {
|
||||
points.push({ x: args[0]!, y: args[1]! });
|
||||
} else if (name === 'move' && args.length >= 4 && args.slice(0, 4).every(Number.isFinite)) {
|
||||
points.push({ x: args[0]!, y: args[1]! });
|
||||
points.push({ x: args[2]!, y: args[3]! });
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function shareCoalesceAnchor(
|
||||
left: readonly AssFragmentPosition[],
|
||||
right: readonly AssFragmentPosition[],
|
||||
): boolean {
|
||||
return right.some((point) =>
|
||||
left.some(
|
||||
(other) =>
|
||||
Math.abs(point.x - other.x) <= LAYER_COPY_OFFSET_TOLERANCE_PX &&
|
||||
Math.abs(point.y - other.y) <= LAYER_COPY_OFFSET_TOLERANCE_PX,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// The set of distinct command names, ignoring arguments and repetition. Two phases of
|
||||
// one effect (pre-echo, highlight, hold) carry different command vocabularies; a re-run
|
||||
// of the same visual (chant burst, countdown frame) repeats the same vocabulary with new
|
||||
// argument values -- including a different number of animation keyframes, which is why
|
||||
// repetition must not count.
|
||||
const structuralSignatureCache = new WeakMap<AnnotatedSubtitleCue, string>();
|
||||
|
||||
function structuralOverrideSignature(cue: AnnotatedSubtitleCue): string {
|
||||
let signature = structuralSignatureCache.get(cue);
|
||||
if (signature === undefined) {
|
||||
const names = new Set(
|
||||
cue.overrides.map(
|
||||
(command) => `${command.animated ? '~' : ''}${command.name.toLowerCase()}`,
|
||||
),
|
||||
);
|
||||
signature = [...names].sort().join(',');
|
||||
structuralSignatureCache.set(cue, signature);
|
||||
}
|
||||
return signature;
|
||||
}
|
||||
|
||||
// A transparent lead-in ends exactly where its glyph's first sung copy begins, so the
|
||||
// echo-to-visible handoff must chain across a small seam.
|
||||
const COALESCE_SEAM_TOLERANCE_SECONDS = 0.05;
|
||||
|
||||
/**
|
||||
* Two same-text copies chain when their windows genuinely overlap. Structurally
|
||||
* identical events are layers of one visual exactly when their windows substantially
|
||||
* coincide; a frame seam or few-millisecond overlap between identical structures is a
|
||||
* re-run (the next chant burst, the next countdown frame). Structurally different
|
||||
* events are phases of one effect and chain across any above-seam overlap. A bare seam
|
||||
* only joins a transparent echo to its visible phase: the lead-in before a highlight
|
||||
* belongs to its glyph, while two visible events that merely abut (the next burst of a
|
||||
* chant, an exit flash after a hold) are separate showings.
|
||||
*/
|
||||
function areTimeConnectedCopies(a: AnnotatedSubtitleCue, b: AnnotatedSubtitleCue): boolean {
|
||||
const overlap = Math.min(a.endTime, b.endTime) - Math.max(a.startTime, b.startTime);
|
||||
if (structuralOverrideSignature(a) === structuralOverrideSignature(b)) {
|
||||
const shorterDuration = Math.min(a.endTime - a.startTime, b.endTime - b.startTime);
|
||||
return overlap >= Math.max(MIN_PHASE_OVERLAP_SECONDS, shorterDuration / 2);
|
||||
}
|
||||
if (overlap >= MIN_PHASE_OVERLAP_SECONDS) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
overlap >= -COALESCE_SEAM_TOLERANCE_SECONDS &&
|
||||
isTransparentFillEcho(a) !== isTransparentFillEcho(b)
|
||||
);
|
||||
}
|
||||
|
||||
function buildCoalescedCopy(members: readonly AnnotatedSubtitleCue[]): AnnotatedSubtitleCue {
|
||||
const ordered = [...members].sort((left, right) => left.order - right.order);
|
||||
const representative =
|
||||
ordered.find((member) =>
|
||||
member.overrides.some((command) => !command.animated && command.name.toLowerCase() === 'pos'),
|
||||
) ?? ordered[0]!;
|
||||
const synthetic: AnnotatedSubtitleCue = {
|
||||
...representative,
|
||||
startTime: earliestStartTime(ordered),
|
||||
endTime: latestEndTime(ordered),
|
||||
order: ordered[0]!.order,
|
||||
};
|
||||
coalescedSourceEvents.set(synthetic, ordered);
|
||||
return synthetic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated glyph effects render one authored glyph as a stack of copies anchored at the
|
||||
* same point: a transparent pre-echo until the syllable is sung, a short highlight, an
|
||||
* exit ghost launched from the anchor, and a steady hold to the end of the line. Their
|
||||
* windows abut rather than coincide, so per-event timing says four unrelated fragments
|
||||
* while the anchor says one glyph. Merging each stack into a single presence spanning
|
||||
* the union window lets timing clusters see the authored line instead of its phases.
|
||||
*/
|
||||
function coalesceAssAnchorCopies(
|
||||
events: readonly AnnotatedSubtitleCue[],
|
||||
): AnnotatedSubtitleCue[] {
|
||||
const buckets = new Map<string, number[]>();
|
||||
const anchorPoints: (AssFragmentPosition[] | null)[] = events.map(() => null);
|
||||
events.forEach((event, index) => {
|
||||
const text = compactCueMatchText(event);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const points = coalesceAnchorPoints(event);
|
||||
if (points.length === 0) {
|
||||
return;
|
||||
}
|
||||
anchorPoints[index] = points;
|
||||
const bucket = buckets.get(text);
|
||||
if (bucket) {
|
||||
bucket.push(index);
|
||||
} else {
|
||||
buckets.set(text, [index]);
|
||||
}
|
||||
});
|
||||
for (const [text, bucket] of buckets) {
|
||||
if (bucket.length > MAX_COALESCE_BUCKET_EVENTS) {
|
||||
// Pathological same-text volume (a whole-episode particle field). Pairing would
|
||||
// stall the main process; uncoalesced events fall back to the burst/grid paths.
|
||||
buckets.delete(text);
|
||||
} else {
|
||||
bucket.sort((left, right) => events[left]!.startTime - events[right]!.startTime);
|
||||
}
|
||||
}
|
||||
|
||||
const parent = events.map((_, index) => index);
|
||||
const findRoot = (index: number): number => {
|
||||
let root = index;
|
||||
while (parent[root] !== root) {
|
||||
root = parent[root]!;
|
||||
}
|
||||
while (parent[index] !== root) {
|
||||
const next = parent[index]!;
|
||||
parent[index] = root;
|
||||
index = next;
|
||||
}
|
||||
return root;
|
||||
};
|
||||
const union = (left: number, right: number): void => {
|
||||
parent[findRoot(left)] = findRoot(right);
|
||||
};
|
||||
|
||||
for (const bucket of buckets.values()) {
|
||||
// Buckets are start-sorted; copies can only connect through time proximity, so the
|
||||
// backward scan stops once no earlier copy's window can still reach this one.
|
||||
const prefixMaxEnd: number[] = [];
|
||||
let maxEnd = -Infinity;
|
||||
for (const index of bucket) {
|
||||
maxEnd = Math.max(maxEnd, events[index]!.endTime);
|
||||
prefixMaxEnd.push(maxEnd);
|
||||
}
|
||||
for (let i = 1; i < bucket.length; i += 1) {
|
||||
const right = bucket[i]!;
|
||||
const reachableStart = events[right]!.startTime - COALESCE_SEAM_TOLERANCE_SECONDS;
|
||||
for (let j = i - 1; j >= 0 && prefixMaxEnd[j]! >= reachableStart; j -= 1) {
|
||||
const left = bucket[j]!;
|
||||
if (
|
||||
shareCoalesceAnchor(anchorPoints[left]!, anchorPoints[right]!) &&
|
||||
areTimeConnectedCopies(events[left]!, events[right]!)
|
||||
) {
|
||||
union(left, right);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const componentsByRoot = new Map<number, AnnotatedSubtitleCue[]>();
|
||||
events.forEach((event, index) => {
|
||||
const root = findRoot(index);
|
||||
const members = componentsByRoot.get(root);
|
||||
if (members) {
|
||||
members.push(event);
|
||||
} else {
|
||||
componentsByRoot.set(root, [event]);
|
||||
}
|
||||
});
|
||||
if (componentsByRoot.size === events.length) {
|
||||
return [...events];
|
||||
}
|
||||
return [...componentsByRoot.values()]
|
||||
.map((members) => (members.length === 1 ? members[0]! : buildCoalescedCopy(members)))
|
||||
.sort((left, right) => left.order - right.order);
|
||||
}
|
||||
|
||||
function hasRelaxedAssFragmentEvidence(events: readonly AnnotatedSubtitleCue[]): boolean {
|
||||
if (events.length < 2 || !events.every((event) => fragmentPlacementAnchors(event).size > 0)) {
|
||||
if (
|
||||
sourceEventCount(events) < 2 ||
|
||||
!events.every((event) => fragmentPlacementAnchors(event).size > 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -408,15 +659,17 @@ function hasRelaxedAssFragmentEvidence(events: readonly AnnotatedSubtitleCue[]):
|
||||
const hasChangingOverrides = events.some(
|
||||
(event) => event.overrideSignature !== first.overrideSignature,
|
||||
);
|
||||
const hasPositionedLayerCopy = events.some((event, index) =>
|
||||
events
|
||||
.slice(0, index)
|
||||
.some(
|
||||
(previous) =>
|
||||
compactCueMatchText(previous) === compactCueMatchText(event) &&
|
||||
isRepeatedFragmentCopy(previous, event),
|
||||
),
|
||||
);
|
||||
const hasPositionedLayerCopy =
|
||||
events.some((event) => sourceEventsOf(event).length > 1) ||
|
||||
events.some((event, index) =>
|
||||
events
|
||||
.slice(0, index)
|
||||
.some(
|
||||
(previous) =>
|
||||
compactCueMatchText(previous) === compactCueMatchText(event) &&
|
||||
isRepeatedFragmentCopy(previous, event),
|
||||
),
|
||||
);
|
||||
return hasChangingOverrides || hasPositionedLayerCopy;
|
||||
}
|
||||
|
||||
@@ -840,6 +1093,14 @@ function intervalsNeverCoexist(intervals: readonly FragmentInterval[]): boolean
|
||||
return true;
|
||||
}
|
||||
|
||||
// A sweep progresses through the syllables of a lyric, so its events carry different
|
||||
// texts. Sequential same-text repaints are one shaking line being redrawn, and its text
|
||||
// must survive to the raw/burst path rather than be suppressed as decoration.
|
||||
function hasMultipleFragmentTexts(events: readonly AnnotatedSubtitleCue[]): boolean {
|
||||
const first = events[0] ? compactCueMatchText(events[0]) : '';
|
||||
return events.some((event) => compactCueMatchText(event) !== first);
|
||||
}
|
||||
|
||||
/**
|
||||
* A karaoke highlight sweep repaints one syllable at a time over an already-visible
|
||||
* lyric line: each event ends as the next begins, so the cluster's concatenated text is
|
||||
@@ -848,6 +1109,9 @@ function intervalsNeverCoexist(intervals: readonly FragmentInterval[]): boolean
|
||||
* placement and timing, so the test is whether any two distinct placements coexist.
|
||||
*/
|
||||
function isProgressiveHighlightSweep(events: readonly AnnotatedSubtitleCue[]): boolean {
|
||||
if (!hasMultipleFragmentTexts(events)) {
|
||||
return false;
|
||||
}
|
||||
const intervals = distinctFragmentIntervals(events);
|
||||
return intervals.length >= 2 && intervalsNeverCoexist(intervals);
|
||||
}
|
||||
@@ -863,7 +1127,9 @@ function isProgressiveHighlightSweep(events: readonly AnnotatedSubtitleCue[]): b
|
||||
*/
|
||||
function isProgressiveHighlightSweepGroup(events: readonly AnnotatedSubtitleCue[]): boolean {
|
||||
if (
|
||||
events.length < MIN_FRAGMENT_LINE_EVENTS ||
|
||||
events.length > MAX_SWEEP_GROUP_EVENTS ||
|
||||
sourceEventCount(events) < MIN_FRAGMENT_LINE_EVENTS ||
|
||||
!hasMultipleFragmentTexts(events) ||
|
||||
!events.every((event) => fragmentPlacementAnchors(event).size > 0) ||
|
||||
!hasAssAnimationEvidence(events)
|
||||
) {
|
||||
@@ -895,41 +1161,136 @@ function decodeSingleAssFragment(cue: AnnotatedSubtitleCue): string | null {
|
||||
return visibleLines.length === 1 ? visibleLines[0]! : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One cluster can hold the same authored text at two granularities: a whole-line event
|
||||
* and the per-glyph events that spell it (an assembly effect renders the line while its
|
||||
* glyph particles converge). Joining both doubles the line. A consecutive run of two or
|
||||
* more parts that concatenates to exactly another part's text is that part's fragment
|
||||
* layer; the whole part keeps the authored spacing, so the run is dropped. Single equal
|
||||
* parts are never dropped -- a repeated word in a lyric is real text, not a layer.
|
||||
*
|
||||
* Spelling alone is not proof: repeated digits after a thousands group also concatenate
|
||||
* to the earlier fragment's text while being real continuation. A duplicate layer sits
|
||||
* on top of its fragments, so the whole part's anchor must fall inside the run's
|
||||
* positional span; text that merely continues the line sits beyond it.
|
||||
*/
|
||||
function isWholePartOverItsRun(whole: AssFragmentPart, run: readonly AssFragmentPart[]): boolean {
|
||||
const wholePosition = fragmentPosition(whole.cue);
|
||||
if (!wholePosition) {
|
||||
return true;
|
||||
}
|
||||
const xs = run
|
||||
.map((part) => fragmentPosition(part.cue)?.x)
|
||||
.filter((x): x is number => Number.isFinite(x));
|
||||
if (xs.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return wholePosition.x >= Math.min(...xs) && wholePosition.x <= Math.max(...xs);
|
||||
}
|
||||
|
||||
function dropFragmentRunsCoveredByWholeParts(parts: AssFragmentPart[]): AssFragmentPart[] {
|
||||
if (parts.length > MAX_FRAGMENT_COLLAPSE_PARTS) {
|
||||
return parts;
|
||||
}
|
||||
const kept = [...parts];
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
const wholes = [...kept].sort(
|
||||
(left, right) =>
|
||||
compactAssMatchText(right.text).length - compactAssMatchText(left.text).length,
|
||||
);
|
||||
for (const whole of wholes) {
|
||||
const wholeText = compactAssMatchText(whole.text);
|
||||
if ([...wholeText].length < 2) {
|
||||
break;
|
||||
}
|
||||
for (let start = 0; start < kept.length && !changed; start += 1) {
|
||||
if (kept[start] === whole) {
|
||||
continue;
|
||||
}
|
||||
let combined = '';
|
||||
for (let end = start; end < kept.length; end += 1) {
|
||||
if (kept[end] === whole) {
|
||||
break;
|
||||
}
|
||||
combined += compactAssMatchText(kept[end]!.text);
|
||||
if (!wholeText.startsWith(combined)) {
|
||||
break;
|
||||
}
|
||||
if (combined === wholeText) {
|
||||
const run = kept.slice(start, end + 1);
|
||||
if (end > start && isWholePartOverItsRun(whole, run)) {
|
||||
kept.splice(start, end - start + 1);
|
||||
changed = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
function reconstructAssFragmentLine(
|
||||
events: readonly AnnotatedSubtitleCue[],
|
||||
): AnnotatedSubtitleCue | null {
|
||||
const hasRelaxedEvidence = hasRelaxedAssFragmentEvidence(events);
|
||||
const minimumEvents = hasRelaxedEvidence ? 2 : MIN_FRAGMENT_LINE_EVENTS;
|
||||
if (events.length < minimumEvents || !hasAssAnimationEvidence(events)) {
|
||||
// Coalesced copies stand in for their source events, so animation-volume thresholds
|
||||
// count sources: a merged four-phase glyph is still four generated events of evidence.
|
||||
if (sourceEventCount(events) < minimumEvents || !hasAssAnimationEvidence(events)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: AssFragmentPart[] = [];
|
||||
const collected: AssFragmentPart[] = [];
|
||||
for (const cue of events) {
|
||||
const text = decodeSingleAssFragment(cue);
|
||||
if (text === null) {
|
||||
return null;
|
||||
}
|
||||
const compactText = compactAssMatchText(text);
|
||||
const isLayerCopy = parts.some(
|
||||
const isLayerCopy = collected.some(
|
||||
(part) =>
|
||||
compactAssMatchText(part.text) === compactText && isRepeatedFragmentCopy(part.cue, cue),
|
||||
);
|
||||
if (!isLayerCopy) {
|
||||
parts.push({ cue, text });
|
||||
collected.push({ cue, text });
|
||||
}
|
||||
}
|
||||
|
||||
const minimumParts = hasRelaxedEvidence ? 1 : MIN_FRAGMENT_LINE_PARTS;
|
||||
if (parts.length < minimumParts || (!hasRelaxedEvidence && parts.length === events.length)) {
|
||||
if (
|
||||
collected.length < minimumParts ||
|
||||
(!hasRelaxedEvidence && collected.length === sourceEventCount(events))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const lengths = parts
|
||||
// A cluster whose every part is one identical glyph is a particle field, not a line.
|
||||
// Reconstructing it would also break the surviving particles' same-text chain that
|
||||
// burst deduplication collapses downstream.
|
||||
if (collected.length >= 2) {
|
||||
const firstText = compactAssMatchText(collected[0]!.text);
|
||||
if (
|
||||
[...firstText].length === 1 &&
|
||||
collected.every((part) => compactAssMatchText(part.text) === firstText)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// The granularity gate judges the cluster as authored, so it runs before redundant
|
||||
// whole-vs-fragments layers collapse: a line plus its glyph swarm is fragment-sized
|
||||
// work even though only the whole-line part survives into the join.
|
||||
const lengths = collected
|
||||
.map((part) => compactAssMatchText(part.text).length)
|
||||
.sort((left, right) => left - right);
|
||||
if ((lengths[Math.floor(lengths.length / 2)] ?? Infinity) > MAX_FRAGMENT_MEDIAN_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
const parts = dropFragmentRunsCoveredByWholeParts(collected);
|
||||
|
||||
const text = joinAssFragmentParts(parts);
|
||||
if (!text) {
|
||||
@@ -938,10 +1299,21 @@ function reconstructAssFragmentLine(
|
||||
const owner = parts[0]!.cue;
|
||||
const animationStartTime = earliestStartTime(events);
|
||||
const animationEndTime = latestEndTime(events);
|
||||
// Publish the window where the line reads as sung text. Transparent pre-echoes render
|
||||
// the upcoming line before its first syllable, and exit ghosts fade past the hold, so
|
||||
// the raw span makes consecutive lyrics overlap on screen. The line starts with its
|
||||
// first opaque copy and holds until the last statically anchored one ends; a line
|
||||
// placed entirely by `\move` has no hold phase and keeps its full visible span.
|
||||
const sources = events.flatMap((event) => [...sourceEventsOf(event)]);
|
||||
const visibleSources = sources.filter((source) => !isTransparentFillEcho(source));
|
||||
const displaySources = visibleSources.length > 0 ? visibleSources : sources;
|
||||
const heldSources = displaySources.filter((source) =>
|
||||
source.overrides.some((command) => !command.animated && command.name.toLowerCase() === 'pos'),
|
||||
);
|
||||
return {
|
||||
...owner,
|
||||
startTime: animationStartTime,
|
||||
endTime: animationEndTime,
|
||||
startTime: earliestStartTime(displaySources),
|
||||
endTime: latestEndTime(heldSources.length > 0 ? heldSources : displaySources),
|
||||
text,
|
||||
rawText: text,
|
||||
source: 'reconstructed-ass',
|
||||
@@ -966,7 +1338,10 @@ function staticFontOverride(cue: AnnotatedSubtitleCue): string | null {
|
||||
|
||||
const MIN_TEXTURE_GLYPH_RUN = 8;
|
||||
const MIN_TEXTURE_ALPHA_OVERRIDES = 6;
|
||||
const MAX_TEXTURE_GLYPHS_PER_ALPHA_OVERRIDE = 2;
|
||||
// Texture payloads switch secondary alpha at nearly every glyph. Authored text that a
|
||||
// typesetter styles in syllable or word chunks measures two or more glyphs per
|
||||
// override, so the seed test demands per-glyph density.
|
||||
const MAX_TEXTURE_GLYPHS_PER_ALPHA_OVERRIDE = 1.5;
|
||||
const MIN_TEXTURE_LAYER_ALPHA = 0xe0;
|
||||
const MAX_TEXTURE_PAYLOAD_FONT_SIZE = 12;
|
||||
const MIN_TEXTURE_PAYLOAD_LINES = 3;
|
||||
@@ -980,11 +1355,14 @@ function hasStaticOverride(cue: AnnotatedSubtitleCue, expectedName: string): boo
|
||||
);
|
||||
}
|
||||
|
||||
function isClippedRepeatedGlyphFragment(cue: AnnotatedSubtitleCue): boolean {
|
||||
function isRepeatedGlyphText(cue: AnnotatedSubtitleCue): boolean {
|
||||
const glyphs = [...compactCueMatchText(cue)];
|
||||
return glyphs.length > 0 && glyphs.every((glyph) => glyph === glyphs[0]);
|
||||
}
|
||||
|
||||
function isClippedRepeatedGlyphFragment(cue: AnnotatedSubtitleCue): boolean {
|
||||
return (
|
||||
glyphs.length > 0 &&
|
||||
glyphs.every((glyph) => glyph === glyphs[0]) &&
|
||||
isRepeatedGlyphText(cue) &&
|
||||
(hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip'))
|
||||
);
|
||||
}
|
||||
@@ -999,11 +1377,22 @@ function isAssTextureSeed(cue: AnnotatedSubtitleCue): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A truncated capture can leave tag debris after the placeholder run, so the seed
|
||||
// test looks for a long same-glyph run inside the clipped text rather than requiring
|
||||
// the whole event to be uniform.
|
||||
const glyphs = [...compactCueMatchText(cue)];
|
||||
const isClippedRepeatedGlyphRun =
|
||||
glyphs.length >= MIN_TEXTURE_GLYPH_RUN && isClippedRepeatedGlyphFragment(cue);
|
||||
if (isClippedRepeatedGlyphRun) {
|
||||
return true;
|
||||
if (hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip')) {
|
||||
let longestRun = 0;
|
||||
let run = 0;
|
||||
let previous = '';
|
||||
for (const glyph of glyphs) {
|
||||
run = glyph === previous ? run + 1 : 1;
|
||||
previous = glyph;
|
||||
longestRun = Math.max(longestRun, run);
|
||||
}
|
||||
if (longestRun >= MIN_TEXTURE_GLYPH_RUN) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (staticFontOverride(cue) === null) {
|
||||
@@ -1026,9 +1415,13 @@ function isAssTextureSeed(cue: AnnotatedSubtitleCue): boolean {
|
||||
}
|
||||
|
||||
function staticGlobalAlpha(cue: AnnotatedSubtitleCue): number | null {
|
||||
return staticAlphaOverride(cue, 'alpha');
|
||||
}
|
||||
|
||||
function staticAlphaOverride(cue: AnnotatedSubtitleCue, expectedName: string): number | null {
|
||||
let alpha: number | null = null;
|
||||
for (const command of cue.overrides) {
|
||||
if (command.animated || command.name.toLowerCase() !== 'alpha') continue;
|
||||
if (command.animated || command.name.toLowerCase() !== expectedName) continue;
|
||||
const match = ASS_ALPHA_VALUE_PATTERN.exec(command.args.trim());
|
||||
const alphaValue = match?.[1];
|
||||
if (alphaValue !== undefined) {
|
||||
@@ -1038,6 +1431,42 @@ function staticGlobalAlpha(cue: AnnotatedSubtitleCue): number | null {
|
||||
return alpha;
|
||||
}
|
||||
|
||||
function hasAnimatedAlphaOverride(cue: AnnotatedSubtitleCue): boolean {
|
||||
return cue.overrides.some((command) => {
|
||||
if (!command.animated) return false;
|
||||
const name = command.name.toLowerCase();
|
||||
return name === '1a' || name === 'alpha';
|
||||
});
|
||||
}
|
||||
|
||||
// `\alpha&HFF&` blanks all four layers, but a later component override can turn one
|
||||
// back on: chant overlays render entirely through `\4a&H00&` shadows. Any re-enabled
|
||||
// layer means the event draws real text.
|
||||
const ASS_COMPONENT_ALPHA_NAMES = ['1a', '2a', '3a', '4a'] as const;
|
||||
|
||||
function hasVisibleComponentAlpha(cue: AnnotatedSubtitleCue): boolean {
|
||||
return ASS_COMPONENT_ALPHA_NAMES.some((name) => {
|
||||
const value = staticAlphaOverride(cue, name);
|
||||
return value !== null && value < 0xff;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A glyph copy whose fill is statically fully transparent and never animated back in is
|
||||
* a glow or outline echo of the real glyph, not the text itself. A coalesced copy chain
|
||||
* counts only when every phase in it is such an echo; one opaque phase means the chain
|
||||
* carries the authored glyph.
|
||||
*/
|
||||
function isTransparentFillEcho(cue: AnnotatedSubtitleCue): boolean {
|
||||
return sourceEventsOf(cue).every(
|
||||
(source) =>
|
||||
(staticAlphaOverride(source, '1a') === 0xff ||
|
||||
staticAlphaOverride(source, 'alpha') === 0xff) &&
|
||||
!hasAnimatedAlphaOverride(source) &&
|
||||
!hasVisibleComponentAlpha(source),
|
||||
);
|
||||
}
|
||||
|
||||
function staticFontSize(cue: AnnotatedSubtitleCue): number | null {
|
||||
let fontSize: number | null = null;
|
||||
for (const command of cue.overrides) {
|
||||
@@ -1090,6 +1519,50 @@ function isTextureFontPayload(
|
||||
);
|
||||
}
|
||||
|
||||
// A sign translation can legitimately render faint text through one or two positioned
|
||||
// events. Dozens of them sharing one window is a texture: near-invisible glyph strings
|
||||
// laid out as pixels of an image, with no visible-text sibling to anchor them.
|
||||
const MIN_TEXTURE_WALL_EVENTS = 6;
|
||||
|
||||
function textureWallGroupKey(cue: AnnotatedSubtitleCue): string {
|
||||
return `${cue.style}\0${cue.startTime}\0${cue.endTime}`;
|
||||
}
|
||||
|
||||
/** Static zero scale or a degenerate static clip renders nothing, unless animation can
|
||||
* still bring the event into view (an entrance growing from `\fscx0`, a clip wipe).
|
||||
* Only an animated scale or clip reveals; `\t(...)` wrapping some other property leaves
|
||||
* the event invisible. Nested `\t(...)` needs no special case because the tags it
|
||||
* animates are recorded as animated in their own right. */
|
||||
function isInvisiblyRenderedEvent(cue: AnnotatedSubtitleCue): boolean {
|
||||
let staticZeroScale = false;
|
||||
let staticZeroClip = false;
|
||||
let animatedReveal = false;
|
||||
for (const command of cue.overrides) {
|
||||
const name = command.name.toLowerCase();
|
||||
if (command.animated) {
|
||||
if (name === 'fscx' || name === 'fscy' || name === 'clip') {
|
||||
animatedReveal = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (name === 'fscx' || name === 'fscy') {
|
||||
if (Number(command.args.trim()) === 0) {
|
||||
staticZeroScale = true;
|
||||
}
|
||||
} else if (name === 'clip') {
|
||||
const args = command.args.split(',').map((value) => Number(value.trim()));
|
||||
if (
|
||||
args.length >= 4 &&
|
||||
args.slice(0, 4).every(Number.isFinite) &&
|
||||
(args[0]! >= args[2]! || args[1]! >= args[3]!)
|
||||
) {
|
||||
staticZeroClip = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (staticZeroScale || staticZeroClip) && !animatedReveal;
|
||||
}
|
||||
|
||||
function assFontTextureGroupKey(cue: AnnotatedSubtitleCue): string | null {
|
||||
const font = staticFontOverride(cue);
|
||||
return font === null ? null : `${cue.style}\0${cue.startTime}\0${cue.endTime}\0${font}`;
|
||||
@@ -1102,16 +1575,6 @@ function assTextureTimingGroupKey(cue: AnnotatedSubtitleCue): string {
|
||||
function removeAssFontTextureEvents(events: ParsedAssEvents): ParsedAssEvents {
|
||||
const seeds = events.dialogue.filter(isAssTextureSeed);
|
||||
const seedSet = new Set(seeds);
|
||||
const textureFontFamilies = new Set(
|
||||
[
|
||||
...seeds,
|
||||
...events.dialogue.filter(isNearlyTransparentPositionedText),
|
||||
...events.comments.filter(isNearlyTransparentPositionedText),
|
||||
]
|
||||
.map(staticFontOverride)
|
||||
.filter((font): font is string => font !== null)
|
||||
.map(textureFontFamilyKey),
|
||||
);
|
||||
// Short pieces can share the seeded font effect under another actor. Matching the
|
||||
// seed's style, timing, and font only narrows the candidates; each piece must still
|
||||
// carry structural texture evidence.
|
||||
@@ -1138,12 +1601,55 @@ function removeAssFontTextureEvents(events: ParsedAssEvents): ParsedAssEvents {
|
||||
const seedIndexesByStyle = new Map(
|
||||
[...seedsByStyle].map(([style, styleSeeds]) => [style, buildAssEventGroupIndex(styleSeeds)]),
|
||||
);
|
||||
const isAssociatedWithTextureSeed = (cue: AnnotatedSubtitleCue): boolean => {
|
||||
const styleSeedIndex = seedIndexesByStyle.get(cue.style);
|
||||
return (
|
||||
styleSeedIndex !== undefined &&
|
||||
eventsOverlappingWindow(styleSeedIndex, cue.startTime, cue.endTime).length > 0
|
||||
);
|
||||
};
|
||||
const textureFontFamilies = new Set(
|
||||
[
|
||||
...seeds,
|
||||
...events.dialogue.filter(
|
||||
(cue) => isNearlyTransparentPositionedText(cue) && isAssociatedWithTextureSeed(cue),
|
||||
),
|
||||
...events.comments.filter(
|
||||
(cue) => isNearlyTransparentPositionedText(cue) && isAssociatedWithTextureSeed(cue),
|
||||
),
|
||||
]
|
||||
.map(staticFontOverride)
|
||||
.filter((font): font is string => font !== null)
|
||||
.map(textureFontFamilyKey),
|
||||
);
|
||||
|
||||
// Wall membership additionally requires that no component alpha turns a layer back
|
||||
// on: `\alpha&HFF&` plus a visible `\4a` renders real text through its shadow, and a
|
||||
// sign typeset entirely from such layers must not read as a texture.
|
||||
const isTextureWallCandidate = (cue: AnnotatedSubtitleCue): boolean =>
|
||||
isNearlyTransparentPositionedText(cue) && !hasVisibleComponentAlpha(cue);
|
||||
const transparentWallCounts = new Map<string, number>();
|
||||
for (const cue of events.dialogue) {
|
||||
if (isTextureWallCandidate(cue)) {
|
||||
const key = textureWallGroupKey(cue);
|
||||
transparentWallCounts.set(key, (transparentWallCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dialogue: events.dialogue.filter((cue) => {
|
||||
if (isInvisiblyRenderedEvent(cue)) {
|
||||
return false;
|
||||
}
|
||||
if (seedSet.has(cue)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
isTextureWallCandidate(cue) &&
|
||||
(transparentWallCounts.get(textureWallGroupKey(cue)) ?? 0) >= MIN_TEXTURE_WALL_EVENTS
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
staticFontOverride(cue) === null &&
|
||||
noFontTextureTimings.has(assTextureTimingGroupKey(cue)) &&
|
||||
@@ -1161,11 +1667,7 @@ function removeAssFontTextureEvents(events: ParsedAssEvents): ParsedAssEvents {
|
||||
if (!isNearlyTransparentPositionedText(cue)) {
|
||||
return true;
|
||||
}
|
||||
const styleSeedIndex = seedIndexesByStyle.get(cue.style);
|
||||
return (
|
||||
styleSeedIndex === undefined ||
|
||||
eventsOverlappingWindow(styleSeedIndex, cue.startTime, cue.endTime).length === 0
|
||||
);
|
||||
return !isAssociatedWithTextureSeed(cue);
|
||||
}),
|
||||
comments: events.comments,
|
||||
};
|
||||
@@ -1226,16 +1728,36 @@ function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): Annotate
|
||||
|
||||
const recovered: AnnotatedSubtitleCue[] = [];
|
||||
const suppressed = new Set<AnnotatedSubtitleCue>();
|
||||
// The published dialogue list holds source events, so a suppressed coalesced copy
|
||||
// chain must suppress every event behind it.
|
||||
const suppress = (event: AnnotatedSubtitleCue): void => {
|
||||
for (const source of sourceEventsOf(event)) {
|
||||
suppressed.add(source);
|
||||
}
|
||||
};
|
||||
for (const events of groups.values()) {
|
||||
const decorative = decorativeGlyphEvents(events);
|
||||
const lineEvents = decorative.size ? events.filter((event) => !decorative.has(event)) : events;
|
||||
const units = coalesceAssAnchorCopies(events);
|
||||
const decorative = decorativeGlyphEvents(units);
|
||||
for (const unit of units) {
|
||||
if (
|
||||
!decorative.has(unit) &&
|
||||
fragmentPlacementAnchors(unit).size > 0 &&
|
||||
isTransparentFillEcho(unit)
|
||||
) {
|
||||
decorative.add(unit);
|
||||
}
|
||||
}
|
||||
const lineEvents = decorative.size ? units.filter((event) => !decorative.has(event)) : units;
|
||||
if (isProgressiveHighlightSweepGroup(lineEvents)) {
|
||||
lineEvents.forEach((event) => suppressed.add(event));
|
||||
lineEvents.forEach(suppress);
|
||||
const spanStart = Math.min(...lineEvents.map((event) => event.startTime));
|
||||
const spanEnd = Math.max(...lineEvents.map((event) => event.endTime));
|
||||
for (const overlay of decorative) {
|
||||
if (overlay.startTime < spanEnd && overlay.endTime > spanStart) {
|
||||
suppressed.add(overlay);
|
||||
if (
|
||||
overlay.startTime < spanEnd + DECORATION_SPAN_TOLERANCE_SECONDS &&
|
||||
overlay.endTime > spanStart - DECORATION_SPAN_TOLERANCE_SECONDS
|
||||
) {
|
||||
suppress(overlay);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -1248,18 +1770,21 @@ function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): Annotate
|
||||
// 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));
|
||||
cluster.events.forEach(suppress);
|
||||
continue;
|
||||
}
|
||||
recovered.push(line);
|
||||
cluster.events.forEach((event) => suppressed.add(event));
|
||||
cluster.events.forEach(suppress);
|
||||
// Decoration is timed to the line it overlays, so it disappears with the line's
|
||||
// full animation span. Decoration outside any recovered span stays published.
|
||||
const spanStart = line.animationStartTime ?? line.startTime;
|
||||
const spanEnd = line.animationEndTime ?? line.endTime;
|
||||
for (const overlay of decorative) {
|
||||
if (overlay.startTime < spanEnd && overlay.endTime > spanStart) {
|
||||
suppressed.add(overlay);
|
||||
if (
|
||||
overlay.startTime < spanEnd + DECORATION_SPAN_TOLERANCE_SECONDS &&
|
||||
overlay.endTime > spanStart - DECORATION_SPAN_TOLERANCE_SECONDS
|
||||
) {
|
||||
suppress(overlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1267,12 +1792,58 @@ function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): Annotate
|
||||
if (recovered.length === 0 && suppressed.size === 0) {
|
||||
return dialogue;
|
||||
}
|
||||
return [...dialogue.filter((cue) => !suppressed.has(cue)), ...recovered].sort(
|
||||
return [
|
||||
...dialogue.filter((cue) => !suppressed.has(cue)),
|
||||
...mergeAbuttingRecoveredLines(recovered),
|
||||
].sort(
|
||||
(left, right) =>
|
||||
left.startTime - right.startTime || left.endTime - right.endTime || left.order - right.order,
|
||||
);
|
||||
}
|
||||
|
||||
// One authored line can reconstruct twice from consecutive effect stages -- its steady
|
||||
// glyphs, then an exit animation replaying the same text. Publishing both would show the
|
||||
// line restarting, so identical recoveries that touch in time collapse into one span.
|
||||
const RECOVERED_LINE_MERGE_GAP_SECONDS = 0.1;
|
||||
|
||||
function mergeAbuttingRecoveredLines(recovered: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
|
||||
const byText = new Map<string, AnnotatedSubtitleCue[]>();
|
||||
for (const line of recovered) {
|
||||
const key = `${assEventGroupKey(line)}\0${compactAssMatchText(line.text)}`;
|
||||
const bucket = byText.get(key);
|
||||
if (bucket) {
|
||||
bucket.push(line);
|
||||
} else {
|
||||
byText.set(key, [line]);
|
||||
}
|
||||
}
|
||||
|
||||
const merged: AnnotatedSubtitleCue[] = [];
|
||||
for (const bucket of byText.values()) {
|
||||
bucket.sort((left, right) => left.startTime - right.startTime || left.order - right.order);
|
||||
let current = bucket[0]!;
|
||||
for (let index = 1; index < bucket.length; index += 1) {
|
||||
const next = bucket[index]!;
|
||||
if (next.startTime <= current.endTime + RECOVERED_LINE_MERGE_GAP_SECONDS) {
|
||||
const endTime = Math.max(current.endTime, next.endTime);
|
||||
current = {
|
||||
...current,
|
||||
endTime,
|
||||
animationEndTime: Math.max(
|
||||
current.animationEndTime ?? current.endTime,
|
||||
next.animationEndTime ?? next.endTime,
|
||||
),
|
||||
};
|
||||
} else {
|
||||
merged.push(current);
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
merged.push(current);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function groupConsecutiveAssFragments(events: readonly AnnotatedSubtitleCue[]): FragmentGroup[] {
|
||||
const groups: FragmentGroup[] = [];
|
||||
for (const event of events) {
|
||||
|
||||
@@ -497,3 +497,30 @@ test('stripCanonicalFragmentLines drops a live glyph wall with no nearby canonic
|
||||
'それよりも ノート…',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText drops a finished lyric whose exit ghosts outlive it beside a raw line', () => {
|
||||
// The reconstructed lyric ended at 6.0 but its exit ghost glyphs stay in the live
|
||||
// text until 7.0, while the next authored line is a plain raw event. The retired cue
|
||||
// must explain the ghost fragments without re-surfacing next to the active line.
|
||||
const cues = [
|
||||
{
|
||||
startTime: 1.0,
|
||||
endTime: 6.0,
|
||||
text: 'エネルギーはサイクル',
|
||||
source: 'reconstructed-ass' as const,
|
||||
animationStartTime: 0.5,
|
||||
animationEndTime: 7.0,
|
||||
assStyle: 'OP - JP',
|
||||
},
|
||||
{ startTime: 6.0, endTime: 12.0, text: '象徴的なパレード' },
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: 'エ\nネ\nル\nギ\nー\n象徴的なパレード',
|
||||
currentTimeSec: 6.5,
|
||||
cues,
|
||||
}),
|
||||
'象徴的なパレード',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -134,14 +134,26 @@ function resolveActiveParsedPrimarySubtitle(options: {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A cue selected only through the edge tolerance has already ended (or not yet
|
||||
// started) by its published timing: a finished lyric whose exit ghosts linger into
|
||||
// the next line. It still explains those live fragments above, but while any cue is
|
||||
// strictly active, only the active cues supply the displayed text. With no strictly
|
||||
// active cue, the edge cues remain the display fallback for stale time-pos readings.
|
||||
const strictlyActive = selected.filter(
|
||||
(cue) => cue.startTime <= options.currentTimeSec && cue.endTime > options.currentTimeSec,
|
||||
);
|
||||
const displayCues = strictlyActive.length > 0 ? strictlyActive : 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'));
|
||||
const texts = uniqueCueTexts(
|
||||
displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid'),
|
||||
);
|
||||
return {
|
||||
text: texts.join('\n'),
|
||||
startTime: Math.min(...selected.map((cue) => cue.startTime)),
|
||||
endTime: Math.max(...selected.map((cue) => cue.endTime)),
|
||||
cues: selected,
|
||||
startTime: Math.min(...displayCues.map((cue) => cue.startTime)),
|
||||
endTime: Math.max(...displayCues.map((cue) => cue.endTime)),
|
||||
cues: displayCues,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ test('parsed secondary text keeps phone translations while dropping texture payl
|
||||
const ass = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Comment: 2,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain Medium\\fs100\\alpha&HFC&}texture seed',
|
||||
'Dialogue: 2,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain Medium\\clip(500,40,660,150)}LLLLLLLLLLLL',
|
||||
'Dialogue: 90,0:00:01.00,0:00:03.00,Default,,0,0,0,,Why did you choose Hanajo instead?',
|
||||
"Dialogue: 1,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain\\fs10\\alpha&H70&}q26D'vrA;\\NE? GS\\NESLhlawEv",
|
||||
"Dialogue: 3,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(582,180)\\fnSF Pro Display\\fs66}We're {\\2a0}running {\\2a1}out {\\2a0}of {\\2a1}time!\\N{\\2a0}Where {\\2a1}are {\\2a0}you {\\2a1}right {\\2a0}now?!",
|
||||
|
||||
Reference in New Issue
Block a user