From 6f52008e5ddb3ba8bb951077486982a7b1ebd581 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 23 Aug 2026 19:16:55 -0700 Subject: [PATCH] fix(subtitles): keep dialogue and wrapped lyrics out of ASS fragment gri - Only classify tall positioned ASS blocks as fragment grids when they read like tiling (sign walls, re-shown countdown frames, scattered single glyphs, or table columns), keeping CC-style dialogue blocks and wrapped lyric rows publishable - Widen Latin word-gap heuristics for per-glyph typesetting runs and short capitalized words so proportional-font variation and two-letter words no longer get split - Add changelog fragment documenting the stats database busy-timeout fix --- changes/ass-canonical-animation-lines.md | 2 +- changes/fix-stats-database-busy-timeout.md | 4 + docs/architecture/subtitle-overlay-priming.md | 9 +- src/core/services/subtitle-cue-parser.test.ts | 184 ++++++++++++++++++ src/core/services/subtitle-cue-parser.ts | 131 ++++++++++++- 5 files changed, 320 insertions(+), 10 deletions(-) create mode 100644 changes/fix-stats-database-busy-timeout.md diff --git a/changes/ass-canonical-animation-lines.md b/changes/ass-canonical-animation-lines.md index 4805bbc8..818ef61f 100644 --- a/changes/ass-canonical-animation-lines.md +++ b/changes/ass-canonical-animation-lines.md @@ -1,5 +1,5 @@ type: fixed area: subtitles -- Typeset ASS karaoke and animated signs no longer flood the primary overlay, subtitle sidebar, immersion history, or sentence mining with repeated glyph fragments or full-line color phases. Matching timed comments and full-line boundary events recover the complete authored line without merging ordinary repeated dialogue or separately positioned signs, and dialogue spoken while a song's animation is on screen is kept intact instead of being replaced by the lyric. Entrance and exit frames that run past the authored line timing still resolve to the clean line during lyric transitions, and dialogue spoken while a song's animation is on screen enters immersion and subtitle history without the fragment lines beside it. +- Typeset ASS karaoke and animated signs no longer flood the primary overlay, subtitle sidebar, immersion history, or sentence mining with repeated glyph fragments or full-line color phases. Matching timed comments and full-line boundary events recover the complete authored line without merging ordinary repeated dialogue or separately positioned signs, and dialogue spoken while a song's animation is on screen is kept intact instead of being replaced by the lyric. Entrance and exit frames that run past the authored line timing still resolve to the clean line during lyric transitions, and dialogue spoken while a song's animation is on screen enters immersion and subtitle history without the fragment lines beside it. Dense visual grids (sign walls, countdown frames, scattered glyph typesetting) stay out of the published text, while multi-row CC-style dialogue blocks and wrapped lyric rows are still published. - 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. diff --git a/changes/fix-stats-database-busy-timeout.md b/changes/fix-stats-database-busy-timeout.md new file mode 100644 index 00000000..7b044c6d --- /dev/null +++ b/changes/fix-stats-database-busy-timeout.md @@ -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. diff --git a/docs/architecture/subtitle-overlay-priming.md b/docs/architecture/subtitle-overlay-priming.md index ac62f5ce..36d9b6e4 100644 --- a/docs/architecture/subtitle-overlay-priming.md +++ b/docs/architecture/subtitle-overlay-priming.md @@ -109,9 +109,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. diff --git a/src/core/services/subtitle-cue-parser.test.ts b/src/core/services/subtitle-cue-parser.test.ts index adbe2173..b2b1e4e9 100644 --- a/src/core/services/subtitle-cue-parser.test.ts +++ b/src/core/services/subtitle-cue-parser.test.ts @@ -1083,6 +1083,190 @@ test('parseSubtitleCues recovers spaces encoded only by positioned Latin glyph g 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'); +}); + +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 repeated syllables publishable', () => { + const fragments = [ + ['dreams', 300, 115], + ['ju', 250, 39], + ['n', 280, 39], + ['jo', 300, 39], + ['u', 330, 39], + ['to', 360, 39], + ['jo', 395, 39], + ['u', 425, 39], + ['ne', 455, 39], + ['tsu!', 485, 39], + ] as const; + const content = [ + ...eventsHeader, + ...[0, 1].flatMap((layer) => + fragments.map( + ([text, x, y], index) => + `Dialogue: ${layer},0:00:01.00,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], diff --git a/src/core/services/subtitle-cue-parser.ts b/src/core/services/subtitle-cue-parser.ts index 95a1f176..bfdaace1 100644 --- a/src/core/services/subtitle-cue-parser.ts +++ b/src/core/services/subtitle-cue-parser.ts @@ -398,7 +398,9 @@ interface AssFragmentPosition { } const MIN_LATIN_POSITION_GAP_SAMPLES = 4; -const LATIN_WORD_GAP_RATIO = 1.2; +const LATIN_FRAGMENT_WORD_GAP_RATIO = 1.16; +const LATIN_GLYPH_WORD_GAP_RATIO = 1.4; +const LATIN_TWO_GLYPH_WORD_NEXT_GAP_RATIO = 1.2; function fragmentPosition(cue: AnnotatedSubtitleCue): AssFragmentPosition | null { for (const command of cue.overrides) { @@ -425,7 +427,8 @@ function fragmentPosition(cue: AnnotatedSubtitleCue): AssFragmentPosition | null } function latinGlyphWidthWeight(glyph: string): number { - if (/[ilIjtfr]/u.test(glyph)) return 0.6; + if (/[ilIj]/u.test(glyph)) return 0.6; + if (/[tfr]/u.test(glyph)) return 0.8; if (/[mwMW]/u.test(glyph)) return 1.4; if (/[A-Z]/u.test(glyph)) return 1.1; return 1; @@ -441,6 +444,10 @@ function latinFragmentWidthWeight(text: string): number | null { ); } +function isSingleLatinGlyphFragment(text: string): boolean { + return [...text].filter((glyph) => /[A-Za-z0-9]/u.test(glyph)).length <= 1; +} + function normalizedLatinFragmentGap( previous: AssFragmentPart, current: AssFragmentPart, @@ -463,6 +470,21 @@ function normalizedLatinFragmentGap( return positionDistance / ((previousWeight + currentWeight) / 2); } +function startsNewPositionedFragmentSequence( + previous: AssFragmentPart, + current: AssFragmentPart, +): boolean { + const previousPosition = fragmentPosition(previous.cue); + const currentPosition = fragmentPosition(current.cue); + return Boolean( + previousPosition && + currentPosition && + Math.abs(currentPosition.y - previousPosition.y) <= 2 && + currentPosition.x <= previousPosition.x && + current.cue.startTime > previous.cue.startTime, + ); +} + function commonLatinFragmentGap(values: readonly number[]): number { const sorted = [...values].sort((left, right) => left - right); // Romaji lines contain many short particles, so real word gaps can outnumber @@ -471,12 +493,40 @@ function commonLatinFragmentGap(values: readonly number[]): number { return sorted[Math.floor((sorted.length - 1) * 0.35)]!; } +function isLikelyTwoGlyphCapitalizedWord(options: { + parts: readonly AssFragmentPart[]; + index: number; + gap: number; + wordGapThreshold: number; +}): boolean { + const first = options.parts[options.index - 1]!; + const second = options.parts[options.index]!; + if (!/^[A-Z]$/u.test(first.text) || !/^[a-z]$/u.test(second.text)) { + return false; + } + + const precedingGap = + options.index > 1 ? normalizedLatinFragmentGap(options.parts[options.index - 2]!, first) : null; + const following = options.parts[options.index + 1]; + const followingGap = following ? normalizedLatinFragmentGap(second, following) : null; + const startsAtWordBoundary = + options.index === 1 || (precedingGap !== null && precedingGap > options.wordGapThreshold); + + return ( + startsAtWordBoundary && + followingGap !== null && + followingGap > options.wordGapThreshold && + followingGap > options.gap * LATIN_TWO_GLYPH_WORD_NEXT_GAP_RATIO + ); +} + /** * Character-by-character typesetting often omits literal spaces because the authored * word gap exists only in each glyph's `\pos`. Estimate the normal adjacent-glyph * advance within that one line, then preserve only materially larger horizontal gaps. * Normalizing each gap by the neighboring fragment widths supports both single glyphs - * and multi-character karaoke syllables without guessing from the text itself. + * and multi-character karaoke syllables without guessing from the text itself. Per-glyph + * runs use a wider safety margin because proportional fonts vary more than syllable chunks. */ function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string { if (parts.some((part) => /\s/u.test(part.text))) { @@ -492,7 +542,10 @@ function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string { } const wordGapThreshold = normalizedGaps.length >= MIN_LATIN_POSITION_GAP_SAMPLES - ? commonLatinFragmentGap(normalizedGaps) * LATIN_WORD_GAP_RATIO + ? commonLatinFragmentGap(normalizedGaps) * + (parts.every((part) => isSingleLatinGlyphFragment(part.text)) + ? LATIN_GLYPH_WORD_GAP_RATIO + : LATIN_FRAGMENT_WORD_GAP_RATIO) : Infinity; let text = parts[0]?.text ?? ''; @@ -501,7 +554,16 @@ function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string { const current = parts[index]!; const hasAuthoredSpace = /\s$/u.test(previous.text) || /^\s/u.test(current.text); const normalizedGap = normalizedLatinFragmentGap(previous, current); - const hasPositionedWordGap = normalizedGap !== null && normalizedGap > wordGapThreshold; + const hasPositionedWordGap = + startsNewPositionedFragmentSequence(previous, current) || + (normalizedGap !== null && + normalizedGap > wordGapThreshold && + !isLikelyTwoGlyphCapitalizedWord({ + parts, + index, + gap: normalizedGap, + wordGapThreshold, + })); if (!hasAuthoredSpace && hasPositionedWordGap) { text += ' '; } @@ -510,6 +572,62 @@ function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string { return text.trim(); } +// A tall multi-part layout is only a visual grid when its parts read like tiling +// rather than prose: a couple of texts repeated across many fragments (sign walls), +// the same text re-shown at the same spot over time (countdown/animation frames), +// nothing but scattered single glyphs, or cells aligned into table columns. Wrapped +// lyric rows with repeated karaoke syllables and CC-style dialogue blocks (speaker +// labels plus a sentence) share the same tall geometry but stay publishable. +function looksLikeFragmentGridParts(parts: readonly AssFragmentPart[]): boolean { + const positioned = parts + .map((part) => ({ + text: part.text.trim(), + layout: part.cue.assLayout, + position: fragmentPosition(part.cue), + startTime: part.cue.startTime, + })) + .filter((part) => part.text && part.layout?.kind === 'positioned'); + if (positioned.length === 0) return true; + + const uniqueTexts = new Set(positioned.map((part) => part.text)); + if (uniqueTexts.size * 3 <= positioned.length) return true; + + if (positioned.every((part) => [...part.text].length <= 1)) return true; + + const seenPlacements = new Map(); + for (const part of positioned) { + if (part.layout?.kind !== 'positioned') continue; + const placement = `${part.text}@${Math.round(part.layout.y)}`; + const earlierStart = seenPlacements.get(placement); + if (earlierStart !== undefined && Math.abs(part.startTime - earlierStart) > 0.01) { + return true; + } + seenPlacements.set(placement, part.startTime); + } + + // Table cells align into columns: several x values each reused on multiple rows. + // Requiring two such columns holding at least half the parts keeps a wrapped lyric + // whose rows accidentally share one x coordinate out of the grid bucket. + const columnRows = new Map>(); + for (const part of positioned) { + if (!part.position) continue; + const x = Math.round(part.position.x); + const rows = columnRows.get(x) ?? new Set(); + rows.add(Math.round(part.position.y)); + columnRows.set(x, rows); + } + let alignedColumns = 0; + let alignedParts = 0; + for (const part of positioned) { + if (!part.position) continue; + if ((columnRows.get(Math.round(part.position.x))?.size ?? 0) >= 2) alignedParts += 1; + } + for (const rows of columnRows.values()) { + if (rows.size >= 2) alignedColumns += 1; + } + return alignedColumns >= 2 && alignedParts * 2 >= positioned.length; +} + function reconstructedAssFragmentLayout( parts: readonly AssFragmentPart[], owner: AnnotatedSubtitleCue, @@ -527,7 +645,8 @@ function reconstructedAssFragmentLayout( if ( positionedPartCount >= MIN_FRAGMENT_LINE_PARTS && - maximumY - minimumY > MAX_FRAGMENT_LINE_VERTICAL_SPAN + maximumY - minimumY > MAX_FRAGMENT_LINE_VERTICAL_SPAN && + looksLikeFragmentGridParts(parts) ) { return { kind: 'fragment-grid', sourceOrder: owner.order }; }