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
This commit is contained in:
2026-08-23 19:16:55 -07:00
parent c4284d1dd4
commit 6f52008e5d
5 changed files with 320 additions and 10 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
type: fixed type: fixed
area: subtitles 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. - The secondary subtitle overlay drops layered duplicate lines from animated tracks, so a short stack of repeated words collapses to its distinct lines even when the full karaoke heuristic does not apply.
@@ -0,0 +1,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.
@@ -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 - 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 and animation collapse. Playback `time-pos` selects the active parsed cue after applying
`secondary-sub-delay`. `secondary-sub-delay`.
- Fragment reconstruction marks positioned parts that span multiple vertical rows as a grid. - Fragment reconstruction marks tall multi-row positioned parts as a grid only when they read
Secondary text omits those grids instead of flattening a translated table or schedule into one like tiling: a couple of texts repeated across many fragments, the same text re-shown at one
synthetic line. Reconstructed single-line karaoke remains eligible for display. 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 - 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 overlay, mining, timing tracker, and immersion statistics therefore consume the same secondary
text when a readable source is available. text when a readable source is available.
@@ -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'); 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', () => { test('parseSubtitleCues adds a missing word space after positioned punctuation', () => {
const fragments = [ const fragments = [
['H', 100], ['H', 100],
+125 -6
View File
@@ -398,7 +398,9 @@ interface AssFragmentPosition {
} }
const MIN_LATIN_POSITION_GAP_SAMPLES = 4; 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 { function fragmentPosition(cue: AnnotatedSubtitleCue): AssFragmentPosition | null {
for (const command of cue.overrides) { for (const command of cue.overrides) {
@@ -425,7 +427,8 @@ function fragmentPosition(cue: AnnotatedSubtitleCue): AssFragmentPosition | null
} }
function latinGlyphWidthWeight(glyph: string): number { 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 (/[mwMW]/u.test(glyph)) return 1.4;
if (/[A-Z]/u.test(glyph)) return 1.1; if (/[A-Z]/u.test(glyph)) return 1.1;
return 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( function normalizedLatinFragmentGap(
previous: AssFragmentPart, previous: AssFragmentPart,
current: AssFragmentPart, current: AssFragmentPart,
@@ -463,6 +470,21 @@ function normalizedLatinFragmentGap(
return positionDistance / ((previousWeight + currentWeight) / 2); 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 { function commonLatinFragmentGap(values: readonly number[]): number {
const sorted = [...values].sort((left, right) => left - right); const sorted = [...values].sort((left, right) => left - right);
// Romaji lines contain many short particles, so real word gaps can outnumber // 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)]!; 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 * 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 * 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. * advance within that one line, then preserve only materially larger horizontal gaps.
* Normalizing each gap by the neighboring fragment widths supports both single glyphs * 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 { function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string {
if (parts.some((part) => /\s/u.test(part.text))) { if (parts.some((part) => /\s/u.test(part.text))) {
@@ -492,7 +542,10 @@ function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string {
} }
const wordGapThreshold = const wordGapThreshold =
normalizedGaps.length >= MIN_LATIN_POSITION_GAP_SAMPLES 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; : Infinity;
let text = parts[0]?.text ?? ''; let text = parts[0]?.text ?? '';
@@ -501,7 +554,16 @@ function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string {
const current = parts[index]!; const current = parts[index]!;
const hasAuthoredSpace = /\s$/u.test(previous.text) || /^\s/u.test(current.text); const hasAuthoredSpace = /\s$/u.test(previous.text) || /^\s/u.test(current.text);
const normalizedGap = normalizedLatinFragmentGap(previous, current); 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) { if (!hasAuthoredSpace && hasPositionedWordGap) {
text += ' '; text += ' ';
} }
@@ -510,6 +572,62 @@ function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string {
return text.trim(); return text.trim();
} }
// A tall multi-part layout is only a visual grid when its parts read like tiling
// rather than prose: a couple of texts repeated across many fragments (sign walls),
// the same text re-shown at the same spot over time (countdown/animation frames),
// nothing but scattered single glyphs, or cells aligned into table columns. Wrapped
// lyric rows with repeated karaoke syllables and CC-style dialogue blocks (speaker
// labels plus a sentence) share the same tall geometry but stay publishable.
function looksLikeFragmentGridParts(parts: readonly AssFragmentPart[]): boolean {
const positioned = parts
.map((part) => ({
text: part.text.trim(),
layout: part.cue.assLayout,
position: fragmentPosition(part.cue),
startTime: part.cue.startTime,
}))
.filter((part) => part.text && part.layout?.kind === 'positioned');
if (positioned.length === 0) return true;
const uniqueTexts = new Set(positioned.map((part) => part.text));
if (uniqueTexts.size * 3 <= positioned.length) return true;
if (positioned.every((part) => [...part.text].length <= 1)) return true;
const seenPlacements = new Map<string, number>();
for (const part of positioned) {
if (part.layout?.kind !== 'positioned') 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<number, Set<number>>();
for (const part of positioned) {
if (!part.position) continue;
const x = Math.round(part.position.x);
const rows = columnRows.get(x) ?? new Set<number>();
rows.add(Math.round(part.position.y));
columnRows.set(x, rows);
}
let alignedColumns = 0;
let alignedParts = 0;
for (const part of positioned) {
if (!part.position) continue;
if ((columnRows.get(Math.round(part.position.x))?.size ?? 0) >= 2) alignedParts += 1;
}
for (const rows of columnRows.values()) {
if (rows.size >= 2) alignedColumns += 1;
}
return alignedColumns >= 2 && alignedParts * 2 >= positioned.length;
}
function reconstructedAssFragmentLayout( function reconstructedAssFragmentLayout(
parts: readonly AssFragmentPart[], parts: readonly AssFragmentPart[],
owner: AnnotatedSubtitleCue, owner: AnnotatedSubtitleCue,
@@ -527,7 +645,8 @@ function reconstructedAssFragmentLayout(
if ( if (
positionedPartCount >= MIN_FRAGMENT_LINE_PARTS && 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 }; return { kind: 'fragment-grid', sourceOrder: owner.order };
} }