fix(subtitles): recover positioned ASS word spacing and drop control debris (#217)

This commit is contained in:
2026-08-25 02:22:21 -07:00
committed by GitHub
parent ed7d3f4c3d
commit e816b3b371
26 changed files with 2934 additions and 105 deletions
+7 -2
View File
@@ -1,5 +1,10 @@
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.
- 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.
- Typeset ASS karaoke and animated signs no longer flood the primary overlay, subtitle sidebar, immersion history, or sentence mining with glyph fragments and per-frame color phases. The authored line is reconstructed once and shown from its first sung syllable until its hold ends, so a lyric's invisible lead-in and fade tail no longer surface it beside the line actually being sung.
- Reconstructed lines keep their authored word spacing, including gaps encoded only by fragment positions or hidden behind wide syllable chunks, so translations stop running words together.
- Ordinary repeated dialogue, separately positioned signs, wrapped lyric rows, and multi-row CC-style dialogue blocks are still published, and dialogue spoken while a song's animation is on screen stays intact instead of being replaced by the lyric.
- Decorative layers stay out of the published text: highlight sweeps, drop-shadow and glow copies, symbol-font glyph decoration, particle-build glyph swarms, sign walls, near-invisible texture strings, and hidden zero-scaled or zero-clipped text.
- On the live overlay, a finished lyric whose exit ghosts still linger in mpv's text keeps explaining those fragments instead of reappearing beside the line that has already taken over.
- Event-heavy karaoke files that stalled subtitle loading for several seconds now parse in well under a second.
- The secondary subtitle overlay collapses layered duplicate lines from animated tracks even when the full karaoke heuristic does not apply.
@@ -1,4 +1,5 @@
type: fixed
area: subtitles
- Prevented embedded subtitle parsing from starving network playback: mounted SMB/NFS media now uses deduplicated mpv live text, while duplicate extraction requests for local media share one ffmpeg process.
- Embedded subtitle tracks on network-mounted (SMB/NFS) media are extracted and parsed again, restoring karaoke reconstruction, sidebar cues, and mining for releases that ship subtitles only inside the container. Extraction reads the file once per episode (roughly 10 seconds per GB on gigabit), its timeout accommodates large Bluray remuxes, and duplicate requests share one ffmpeg process. Only true remote URLs keep the live-text-only path.
- While extraction is still running, or when no parsed cues exist (remote URLs, unreadable sources), per-glyph typeset karaoke no longer shows a wall of scattered letters in the overlays; concurrent dialogue lines still display.
+2 -2
View File
@@ -1,5 +1,5 @@
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.
- 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 pin an OP lyric to the top of "Top Repeated Words" for good. Lines are now collapsed on the way in using the same rules the subtitle sidebar applies, with a strict fallback for shifted or unparsed sources. Ordinary repeated dialogue and rewatches are unaffected.
- Added a cleanup for stats already affected. The Vocabulary tab's **Duplicates** button 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>`. Watch time and lines-seen totals are left as recorded.
+2 -1
View File
@@ -1,4 +1,5 @@
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.
- Primary and secondary ASS subtitles collapse layered and whitespace variants of full-span lyrics, including when playback starts or seeks into a line, and reconstruct fragment-only karaoke per style while preserving the authored stack order.
- Canonical signs stay visible for their complete generated animation, song lyrics are navigated by sanitized lines instead of generated animation events, and sidebar selections stay on the requested overlapping lyric with unmatched dialogue and signs preserved.
@@ -1,4 +1,6 @@
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 instead of joining every word together. 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 through the primary subtitle deduplication pipeline, so layered animation text stops appearing several times in the overlay, mined cards, and statistics.
- Long ASS lines repeated as dialogue or as positioned signs collapse when they differ only in whitespace or terminal punctuation, and 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 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.
+3 -3
View File
@@ -1,6 +1,6 @@
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.
- Library: duplicate cards for the same show can 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, emptied entries disappear, and remembered title aliases keep future episodes on the merged card.
- Library: episodes can be reassigned to another entry from the "→" button on an episode row, which is the fix when one file lands under a stray title (for example an episode name parsed as the series). Manual assignments survive later filename parsing, Jellyfin refreshes, and season repair, and 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.
@@ -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
+21 -9
View File
@@ -98,8 +98,15 @@ coming and prefetching would otherwise idle for the rest of the cue.
## Secondary Subtitle Flow
- `secondary-sub-text` remains the immediate fallback, so unreadable subtitle sources, remote URLs,
and files on network mounts still appear without waiting for file resolution. Embedded-track
extraction is skipped for those sources to avoid competing with playback for network bandwidth.
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.
@@ -109,9 +116,12 @@ coming and prefetching would otherwise idle for the rest of the cue.
- 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.
@@ -120,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.
+25
View File
@@ -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);
});
+35
View File
@@ -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',
@@ -550,6 +556,29 @@ test('parseSubtitleCues recovers a full Dialogue line surrounding generated frag
]);
});
test('parseSubtitleCues replaces animated glyph copies of a static canonical Dialogue line', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:00:01.00,0:00:04.00,OP - JP,,0,0,0,,重複字幕',
'Dialogue: 2,0:00:01.00,0:00:04.00,OP - JP,,0,0,0,,{\\pos(400,50)\\t(0,100,\\fry0)}重',
'Dialogue: 2,0:00:01.10,0:00:04.00,OP - JP,,0,0,0,,{\\pos(440,50)\\t(0,100,\\fry0)}複',
'Dialogue: 2,0:00:01.20,0:00:04.00,OP - JP,,0,0,0,,{\\pos(480,50)\\t(0,100,\\fry0)}字',
'Dialogue: 2,0:00:01.30,0:00:04.00,OP - JP,,0,0,0,,{\\pos(520,50)\\t(0,100,\\fry0)}幕',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{
startTime: 1,
endTime: 4,
text: '重複字幕',
source: 'canonical-ass',
animationStartTime: 1,
animationEndTime: 4,
},
]);
});
test('parseSubtitleCues does not promote a short animated fragment as a complete line', () => {
const content = [
'[Events]',
@@ -1021,3 +1050,950 @@ test('parseSubtitleCues detects subtitle formats from remote URLs', () => {
assert.equal(cues.length, 1);
assert.equal(cues[0]!.text, 'URLテスト');
});
test('parseSubtitleCues skips zero-duration ASS metadata events', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:00.00,0:00:00.00,Default,,0,0,0,,[Script Info]',
'Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,Real subtitle',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{ startTime: 1, endTime: 2, text: 'Real subtitle' },
]);
});
test('parseSubtitleCues drops malformed ASS spacer reset debris', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:02.00,Background,,0,0,0,,{\\pos(10,10)}\\h\\h\\h\\{\\fr0',
'Dialogue: 1,0:00:01.00,0:00:02.00,Default,,0,0,0,,Visible line',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{ startTime: 1, endTime: 2, text: 'Visible line' },
]);
});
test('parseSubtitleCues recovers spaces encoded only by positioned Latin glyph gaps', () => {
const glyphs = [
['T', 100],
['h', 118],
['e', 136],
['s', 164],
['t', 178],
['a', 194],
['r', 210],
['s', 227],
['I', 255],
['s', 275],
['e', 293],
['e', 311],
] as const;
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
glyphs.map(
([glyph, x], index) =>
`Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'The stars I see');
});
test('parseSubtitleCues does not split narrow letters inside positioned English words', () => {
const text = 'carryinghappiness';
const positions = [
323, 341, 356, 369, 383, 396, 410, 428, 456, 474, 493, 512, 526, 540, 558, 575, 590,
];
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
[...text].map(
(glyph, index) =>
`Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${positions[index]},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'carrying happiness');
});
test('parseSubtitleCues keeps proportional-font variation inside positioned English words', () => {
const text = 'sendsripplesacrossthestillnessofyourheart';
const positions = [
32, 46, 60, 78, 95, 121, 131, 144, 163, 177, 189, 203, 232, 248, 261, 274, 288, 302, 329, 346,
363, 390, 405, 416, 423, 432, 443, 457, 471, 485, 512, 525, 551, 564, 579, 593, 622, 639, 655,
670, 684,
];
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
[...text].map(
(glyph, index) =>
`Dialogue: ${layer},0:00:01.00,0:00:04.00,Insert English,,0,0,0,,{\\pos(${positions[index]},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
),
),
].join('\n');
assert.equal(
parseSubtitleCues(content, 'test.ass')[0]?.text,
'sends ripples across the stillness of your heart',
);
});
test('parseSubtitleCues keeps a short capitalized word when the following gap is larger', () => {
const text = 'IfIgrow';
const positions = [347, 365, 397, 430, 446, 463, 485];
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
[...text].map(
(glyph, index) =>
`Dialogue: ${layer},0:00:01.00,0:00:04.00,Insert English,,0,0,0,,{\\pos(${positions[index]},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'If I grow');
});
// Geometry taken from a real per-glyph ED line. The `waves within` gap crosses a wide
// `w`, so the width-normalized ratio reads it as a common advance; only the constant
// extra distance of the authored word space gives it away.
test('parseSubtitleCues recovers a word gap measured across a wide glyph', () => {
const text = 'youcanhearthesoundofthewaveswithinmyheart';
const positions = [
202, 223, 244, 274, 296, 317, 346, 367, 389, 408, 433, 450, 470, 499, 517, 538, 557, 578, 609,
627, 651, 668, 688, 723, 748, 770, 792, 811, 843, 863, 875, 892, 907, 922, 957, 983, 1013, 1034,
1055, 1075, 1090,
];
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
[...text].map(
(glyph, index) =>
`Dialogue: ${layer},0:00:01.00,0:00:04.00,ED English,,0,0,0,,{\\pos(${positions[index]},687)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
),
),
].join('\n');
assert.equal(
parseSubtitleCues(content, 'test.ass')[0]?.text,
'you can hear the sound of the waves within my heart',
);
});
// A single short word gives too few gap samples to trust the excess rule: its narrow
// glyphs skew the common advance low and `w e` would read as a word gap.
test('parseSubtitleCues does not split a short single positioned word', () => {
const text = 'Swelling';
const positions = [592, 613, 635, 647, 655, 662, 673, 689];
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
[...text].map(
(glyph, index) =>
`Dialogue: ${layer},0:00:01.00,0:00:04.00,ED English,,0,0,0,,{\\pos(${positions[index]},682)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'Swelling');
});
// A capitalized word whose first letter sits before a wide glyph (`S|miles`) overruns
// the width table; the excess rule must not split a capital from its lowercase run.
test('parseSubtitleCues keeps a capitalized word intact under the excess rule', () => {
const text = 'Smilesarebudding';
const positions = [37, 63, 80, 88, 99, 113, 142, 157, 171, 201, 217, 235, 255, 269, 280, 295];
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
[...text].map(
(glyph, index) =>
`Dialogue: ${layer},0:00:01.00,0:00:04.00,ED English,,0,0,0,,{\\pos(${positions[index]},682)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'Smiles are budding');
});
// Mirrors a real ED: per-syllable romaji at y=34 overlaid with animated single letters
// at y=29 rendered through `\fn` in a symbol font, where `a` draws as a sparkle. The
// letters must neither join the reconstructed line nor survive as their own cues.
test('parseSubtitleCues drops symbol-font glyph decoration from a reconstructed line', () => {
const syllables = [
['so', 479],
['t', 505],
['to', 529],
['mi', 577],
['mi', 618],
['ni', 663],
['a', 699],
['te', 728],
['ru', 764],
['to', 810],
] as const;
const decoration = [
['a', 479, '0:00:01.25'],
['z', 577, '0:00:02.51'],
['x', 618, '0:00:02.78'],
['q', 505, '0:00:04.20'],
] as const;
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
syllables.map(
([syllable, x], index) =>
`Dialogue: ${layer},0:00:01.00,0:00:05.37,ED Romaji,,0,0,0,fx,{\\an5\\pos(${x},34)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${syllable}`,
),
),
...decoration.map(
([glyph, x, start]) =>
`Dialogue: 0,${start},0:00:05.37,ED Romaji,,0,0,0,fx,{\\pos(${x},29)\\fnSplit splat splodge\\fs28\\t(3870,3970,\\fscx105)}${glyph}`,
),
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 1);
assert.equal(cues[0]?.text, 'sotto mimi ni ateru to');
});
test('parseSubtitleCues drops clipped repeated-glyph texture text', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
"Dialogue: 10,0:00:01.00,0:00:04.00,Default,,0,0,0,,I'm blocking them.",
'Dialogue: 2,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,80)\\fnSerangkaian Pattern Regular\\clip(800,20,1120,140)}LLLLLLLLLLLLLLLLLLLLLLLL',
'Dialogue: 3,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,150)\\fnSF Pro Display}Enter a message',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
["I'm blocking them.", 'Enter a message'],
);
});
test('parseSubtitleCues preserves opaque same-font text beside texture fragments', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 2,0:00:01.00,0:00:04.00,MarySigns,seed,0,0,0,,{\\pos(960,80)\\fnSerangkaian Pattern Regular\\clip(800,20,1120,140)}LLLLLLLLLLLLLLLLLLLLLLLL',
'Dialogue: 2,0:00:01.00,0:00:04.00,MarySigns,piece,0,0,0,,{\\pos(960,110)\\fnSerangkaian Pattern Regular\\clip(800,20,1120,140)}LLLL',
'Dialogue: 3,0:00:01.00,0:00:04.00,MarySigns,label,0,0,0,,{\\pos(960,150)\\fnSerangkaian Pattern Regular}Keep this label',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
['Keep this label'],
);
});
test('parseSubtitleCues drops tiny alpha payloads from a proven texture font', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 2,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain Medium\\clip(500,40,660,150)}LLLLLLLLLLLL',
"Dialogue: 1,0:00:06.00,0:00:09.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain\\fs10\\alpha&H70&}q26D'vrA;\\NE? GS\\NESLhlawEv",
'Dialogue: 3,0:00:06.00,0:00:09.00,FrogSigns,,0,0,0,,{\\pos(1040,620)\\fnSF Pro Display\\fs66}Waiting!',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
['Waiting!'],
);
});
test('parseSubtitleCues preserves a small multiline translation using an unverified transparent font', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 2,0:00:01.00,0:00:04.00,Transition,,0,0,0,,{\\pos(580,95)\\fnPhone UI\\fs60\\alpha&HF0&}Faded transition',
'Dialogue: 3,0:00:06.00,0:00:09.00,Phone,,0,0,0,,{\\pos(1040,620)\\fnPhone UI\\fs10\\alpha&H70&}Call me when you arrive.\\NI will still be awake.\\NDo not rush.',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
['Faded transition', 'Call me when you arrive.\nI will still be awake.\nDo not rush.'],
);
});
test('parseSubtitleCues drops clipped repeated-glyph texture text without a font override', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\an7\\pos(736.49,152.99)\\fscy150\\fs10\\bord3\\c&H657BC8&\\3c&H657BC8&\\blur3\\clip}lllllllllllll',
'Dialogue: 0,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\an7\\pos(769.9,106.18)\\fscy150\\fs12\\bord3\\c&H66729F&\\3c&H66729F&\\blur5\\clip}llll',
'Dialogue: 5,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(893,311)}Read',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
['Read'],
);
});
test('parseSubtitleCues drops per-character alpha texture text', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
"Dialogue: 10,0:00:01.00,0:00:04.00,Default,Girl,0,0,0,,So Doloris was actually Uika-chan from sumimi! That's amazing!",
"Dialogue: 2,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,240)\\fnCinzel}Hanasakigawa Girl's School",
'Dialogue: 3,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,300)\\fnSplit splat splodge\\clip(800,200,1120,400)}d{\\2a1}s{\\2a0}h{\\2a1}f{\\2a0}k{\\2a1}h{\\2a0}f{\\2a1}s{\\2a0}d{\\2a1}f{\\2a0}e',
'Dialogue: 3,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(980,340)\\fnSplit splat splodge}f {\\2a1}a',
'Dialogue: 4,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,360)\\fnGrain SemiBold}5{\\2a1}X{\\2a0}N{\\2a1}T{\\2a0}f{\\2a1}I{\\2a0}g{\\2a1}F{\\2a0}B{\\2a1}?{\\2a0}k{\\2a1}u{\\2a0}C{\\2a1}m',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
[
"So Doloris was actually Uika-chan from sumimi! That's amazing!",
"Hanasakigawa Girl's School",
],
);
});
test('parseSubtitleCues drops transparent texture payloads across an animated sign', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
"Dialogue: 90,0:00:01.00,0:00:04.00,Alt,,0,0,0,,Even if you want to see her, she doesn't want to see you!",
'Dialogue: 0,0:00:01.00,0:00:01.08,FrogSigns,,0,0,0,,{\\pos(699,803)\\fnSerangkaian Pattern Regular\\clip(300,380,1130,1050)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a0}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\\\\\\\\\\\\\\\\\\\\\',
'Dialogue: 3,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnGrain\\alpha&HE0&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
'Dialogue: 5,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnRoboto Medium\\alpha&H00&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
'Dialogue: 6,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnGrain\\alpha&HE0&}H1.4igcAhGYHVWD"kHcVlG2W9eKEWj"!X\\N\'uNVaEVpTXMd9rk7dnRX\'P!RhsS"Wn90k6',
'Dialogue: 6,0:00:01.00,0:00:01.08,FrogSigns,18K,0,0,0,,{\\pos(284,821)\\fnGrain\\alpha&HE0&}ou:QepiiPqQ.4n.IYbFaGHtPzWyKI9CUSq:',
'Dialogue: 1,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(581,921)\\fnSerangkaian Pattern Regular\\clip(195,495,986,1120)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{',
'Dialogue: 3,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnGrain\\alpha&HE0&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
'Dialogue: 5,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnRoboto Medium\\alpha&H00&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
'Dialogue: 3,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnGrain\\alpha&HF0&}9LF\'GpPCTlOkLxBLV:QN,8R8NUVM"ha.s\\NNUUPNTBdJih4jUthK34i,yYe;9EBgLXbET',
"Dialogue: 6,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(150,936)\\fnGrain\\alpha&HE0&}JS7vl:lD;'PzkCb!bGT;.7TbA.KCkEH0LOk",
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
[
'Street performance by Mortis from\nMujica - Acting prodigy in action!',
"Even if you want to see her, she doesn't want to see you!",
'Street performance by Mortis from\nMujica - Acting prodigy in action!',
],
);
});
test('parseSubtitleCues does not reconstruct short texture pieces under another actor', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,bubble,0,0,0,,{\\pos(245,-102)\\fnSerangkaian Pattern Regular\\clip(224,-1,831,106)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L',
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(917,293)\\alpha&H20&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(911,293)\\alpha&H58&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(845,300)\\alpha&H00&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(907,293)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,289,1010,338)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(911,293)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,289,1010,338)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(922,130)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,120,1010,173)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 5,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(893,311)\\fnSFProDisplay-Regular-STR}Read 3',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
['Read 3'],
);
});
test('parseSubtitleCues separates overlapping positioned English lyric sequences', () => {
const fragments = [
['my', 642, '0:00:01.00', '0:00:04.05'],
['song!', 713, '0:00:01.00', '0:00:04.05'],
['I', 533, '0:00:01.67', '0:00:04.09'],
['h', 557, '0:00:01.67', '0:00:04.09'],
['u', 575, '0:00:01.67', '0:00:04.09'],
['m', 597, '0:00:01.67', '0:00:04.09'],
] as const;
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
fragments.map(
([text, x, start, end], index) =>
`Dialogue: ${layer},${start},${end},OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${text}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'my song! I hum');
});
const eventsHeader = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
];
test('parseSubtitleCues keeps 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',
);
});
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'],
);
});
File diff suppressed because it is too large Load Diff
-1
View File
@@ -2059,7 +2059,6 @@ const cachedInternalSubtitleTrackExtractor = createCachedInternalSubtitleTrackEx
const detectRemoteMediaPath = createRemoteMediaPathDetector();
const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler({
getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg',
isRemoteMediaPath: detectRemoteMediaPath,
extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) =>
cachedInternalSubtitleTrackExtractor.extract(ffmpegPath, videoPath, track),
logDebug: (message) => logger.debug(message),
+4 -3
View File
@@ -864,15 +864,16 @@ test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active
);
});
test('main process guards internal subtitle extraction with the remote media detector', () => {
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);
assert.match(source, /const detectRemoteMediaPath = createRemoteMediaPathDetector\(\);/);
assert.match(resolverWiring, /isRemoteMediaPath:\s*detectRemoteMediaPath/);
// 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/,
@@ -46,7 +46,10 @@ export type InternalSubtitleTrackExtractor = (
track: MpvSubtitleTrackLike,
) => Promise<ExtractedInternalSubtitleTrack | null>;
const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000;
// 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) {
@@ -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', () => {
+8 -1
View File
@@ -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,
@@ -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,50 @@ 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: [],
}),
'それよりも ノート…',
);
});
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,
}),
'象徴的なパレード',
);
});
+80 -24
View File
@@ -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,23 +106,54 @@ 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);
// 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(
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,
};
}
@@ -179,8 +229,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 +239,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 +260,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(
@@ -72,6 +98,22 @@ test('parsed secondary text drops a reconstructed grid of positioned sign fragme
);
});
test('parsed secondary text keeps phone translations while dropping texture payloads', () => {
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 2,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain Medium\\clip(500,40,660,150)}LLLLLLLLLLLL',
'Dialogue: 90,0:00:01.00,0:00:03.00,Default,,0,0,0,,Why did you choose Hanajo instead?',
"Dialogue: 1,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain\\fs10\\alpha&H70&}q26D'vrA;\\NE? GS\\NESLhlawEv",
"Dialogue: 3,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(582,180)\\fnSF Pro Display\\fs66}We're {\\2a0}running {\\2a1}out {\\2a0}of {\\2a1}time!\\N{\\2a0}Where {\\2a1}are {\\2a0}you {\\2a1}right {\\2a0}now?!",
].join('\n');
assert.equal(
findActiveSubtitleText(parseSubtitleCues(ass, 'phone.ass'), 2),
"Why did you choose Hanajo instead?\nWe're running out of time!\nWhere are you right now?!",
);
});
test('parsed secondary lyrics keep explicit ASS vertical order when durations alternate', () => {
const lyric = (options: { start: string; end: string; style: string; y: number; text: string }) =>
`Dialogue: 0,0:00:${options.start},0:00:${options.end},${options.style},,0,0,0,fx,{\\move(100,${options.y},120,${options.y})\\t(0,200,\\fscx110)}${options.text}\\N{\\p1}m 0 0 l 0 5`;
@@ -144,6 +186,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: 'Its 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,
@@ -380,6 +446,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;
@@ -471,3 +656,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, 'それよりも ノート…');
});
+50 -10
View File
@@ -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 {
@@ -157,14 +157,16 @@ test('subtitle prefetch runtime does not extract internal subtitle tracks from r
assert.equal(extracted, false);
});
test('subtitle prefetch runtime does not extract internal subtitle tracks from network mounts', async () => {
test('subtitle prefetch runtime extracts internal subtitle tracks from network-mounted media', async () => {
let extracted = false;
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
getFfmpegPath: () => 'ffmpeg-custom',
isRemoteMediaPath: async (videoPath) => videoPath.startsWith('/Volumes/jellyfin/'),
extractInternalSubtitleTrack: async () => {
extracted = true;
return null;
return {
path: '/tmp/subminer-sidebar-123/track_7.ass',
cleanup: async () => {},
};
},
});
@@ -181,8 +183,8 @@ test('subtitle prefetch runtime does not extract internal subtitle tracks from n
videoPath: '/Volumes/jellyfin/movie.mkv',
});
assert.equal(resolved, null);
assert.equal(extracted, false);
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 () => {
@@ -17,8 +17,6 @@ type ActiveSubtitleSidebarSource = {
cleanup?: () => Promise<void>;
};
type RemoteMediaPathDetector = (mediaPath: string) => boolean | Promise<boolean>;
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
@@ -88,7 +86,6 @@ function getActiveSubtitleTrack(
export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
getFfmpegPath: () => string;
isRemoteMediaPath?: RemoteMediaPathDetector;
extractInternalSubtitleTrack: (
ffmpegPath: string,
videoPath: string,
@@ -129,8 +126,10 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
return { path: externalFilename, sourceKey: externalFilename };
}
const isRemoteMediaPath = deps.isRemoteMediaPath ?? isRemoteMediaUrl;
if (await 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;
}