fix(subtitles): drop symbol-font glyph decoration and recover wide-glyph word gaps

Generated lyric effects can overlay each syllable with animated single letters
rendered through \fn in a symbol font, where ordinary letters draw as sparkles.
Reading them as text corrupted reconstructed lines ("sotto mimi ni ateru to a z
x") and leaked junk cues ("hlk"). A font a style group uses only for scattered
animated single glyphs now marks those events as decoration: they stay out of
fragment reconstruction and are suppressed alongside the line they overlay.

Per-glyph word gaps measured across a wide glyph ("waves|within" over s/w)
normalize to nearly a common advance, so the ratio test missed them. A word
space adds a roughly constant extra distance regardless of neighbor widths, so
glyph runs with enough gap samples also split when the advance exceeds the
width-predicted advance by a material fraction of the line's common unit.
Capital-to-lowercase pairs and short sample counts are excluded; both guards
are pinned by corpus-derived regression tests.

Across the 145-file library corpus this removes every scattered-letter
malformation and recovers 30+ missing word spaces with no other output change.
This commit is contained in:
2026-08-23 20:33:04 -07:00
parent 6f52008e5d
commit 9f08adbfb9
4 changed files with 225 additions and 15 deletions
+1 -1
View File
@@ -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. 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.
- Typeset ASS karaoke and animated signs no longer flood the primary overlay, subtitle sidebar, immersion history, or sentence mining with repeated glyph fragments or full-line color phases. Matching timed comments and full-line boundary events recover the complete authored line without merging ordinary repeated dialogue or separately positioned signs, and dialogue spoken while a song's animation is on screen is kept intact instead of being replaced by the lyric. Entrance and exit frames that run past the authored line timing still resolve to the clean line during lyric transitions, and dialogue spoken while a song's animation is on screen enters immersion and subtitle history without the fragment lines beside it. Dense visual grids (sign walls, countdown frames, scattered glyph typesetting) stay out of the published text, while multi-row CC-style dialogue blocks and wrapped lyric rows are still published. Decorative letters that lyric effects render in symbol fonts over the syllables are dropped with the animation instead of corrupting the reconstructed line or leaking as stray cues.
- The secondary subtitle overlay drops layered duplicate lines from animated tracks, so a short stack of repeated words collapses to its distinct lines even when the full karaoke heuristic does not apply.
@@ -1,4 +1,4 @@
type: fixed
area: overlay
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Fragmented ASS karaoke keeps spaces authored at event boundaries and recovers Latin word spaces encoded only by positioned fragment gaps. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become concatenated primary or secondary lines. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display.
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Fragmented ASS karaoke keeps spaces authored at event boundaries and recovers Latin word spaces encoded only by positioned fragment gaps, including word gaps measured across wide glyphs that width normalization alone reads as ordinary letter advances. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become concatenated primary or secondary lines. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display.
@@ -1143,6 +1143,113 @@ test('parseSubtitleCues keeps a short capitalized word when the following gap is
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 separates overlapping positioned English lyric sequences', () => {
const fragments = [
['my', 642, '0:00:01.00', '0:00:04.05'],
+116 -13
View File
@@ -2,6 +2,7 @@ import {
assOverrideSignature,
assToPlainText,
collectAssOverrideCommands,
hasAssTemporalOverride,
parseAssEffectField,
removeAssControlDebrisLines,
type AssEffectKind,
@@ -400,6 +401,13 @@ interface AssFragmentPosition {
const MIN_LATIN_POSITION_GAP_SAMPLES = 4;
const LATIN_FRAGMENT_WORD_GAP_RATIO = 1.16;
const LATIN_GLYPH_WORD_GAP_RATIO = 1.4;
// Word-space advance beyond the width-predicted glyph advance, as a fraction of the
// line's common unit. Measured corpus extremes: widest within-word excess 0.32 (`pp`
// with tracking), narrowest word gap 0.40 (`s w` across a wide glyph). That margin only
// holds when the common unit is estimated from enough glyph pairs; a short single-word
// line (`Swelling`) skews the unit low and its ordinary advances read as word gaps.
const LATIN_GLYPH_WORD_EXCESS_RATIO = 0.36;
const MIN_LATIN_GLYPH_EXCESS_GAP_SAMPLES = 10;
const LATIN_TWO_GLYPH_WORD_NEXT_GAP_RATIO = 1.2;
function fragmentPosition(cue: AnnotatedSubtitleCue): AssFragmentPosition | null {
@@ -448,10 +456,15 @@ function isSingleLatinGlyphFragment(text: string): boolean {
return [...text].filter((glyph) => /[A-Za-z0-9]/u.test(glyph)).length <= 1;
}
function normalizedLatinFragmentGap(
interface LatinFragmentGapMeasure {
distance: number;
meanWeight: number;
}
function latinFragmentGapMeasure(
previous: AssFragmentPart,
current: AssFragmentPart,
): number | null {
): LatinFragmentGapMeasure | null {
const previousWeight = latinFragmentWidthWeight(previous.text);
const currentWeight = latinFragmentWidthWeight(current.text);
const previousPosition = fragmentPosition(previous.cue);
@@ -466,8 +479,16 @@ function normalizedLatinFragmentGap(
// A wrapped authored line can return to the left on its next visual row. Preserve
// that measured row transition as a separator without treating backwards movement
// on the same row as a word gap.
const positionDistance = yDistance <= 2 ? xDistance : Math.abs(xDistance) + yDistance;
return positionDistance / ((previousWeight + currentWeight) / 2);
const distance = yDistance <= 2 ? xDistance : Math.abs(xDistance) + yDistance;
return { distance, meanWeight: (previousWeight + currentWeight) / 2 };
}
function normalizedLatinFragmentGap(
previous: AssFragmentPart,
current: AssFragmentPart,
): number | null {
const measure = latinFragmentGapMeasure(previous, current);
return measure === null ? null : measure.distance / measure.meanWeight;
}
function startsNewPositionedFragmentSequence(
@@ -527,6 +548,12 @@ function isLikelyTwoGlyphCapitalizedWord(options: {
* Normalizing each gap by the neighboring fragment widths supports both single glyphs
* and multi-character karaoke syllables without guessing from the text itself. Per-glyph
* runs use a wider safety margin because proportional fonts vary more than syllable chunks.
*
* The ratio test alone under-detects a word gap next to a wide glyph (`waves within`
* measured across `s`/`w` normalizes to nearly a common advance), so per-glyph runs also
* treat a gap as a word boundary when its advance exceeds the width-predicted advance by
* a material fraction of the line's common unit -- a word space adds a roughly constant
* extra distance no matter how wide its neighbors are.
*/
function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string {
if (parts.some((part) => /\s/u.test(part.text))) {
@@ -540,24 +567,38 @@ function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string {
const gap = normalizedLatinFragmentGap(parts[index - 1]!, parts[index]!);
if (gap !== null) normalizedGaps.push(gap);
}
const wordGapThreshold =
const isGlyphRun = parts.every((part) => isSingleLatinGlyphFragment(part.text));
const commonGap =
normalizedGaps.length >= MIN_LATIN_POSITION_GAP_SAMPLES
? commonLatinFragmentGap(normalizedGaps) *
(parts.every((part) => isSingleLatinGlyphFragment(part.text))
? LATIN_GLYPH_WORD_GAP_RATIO
: LATIN_FRAGMENT_WORD_GAP_RATIO)
: Infinity;
? commonLatinFragmentGap(normalizedGaps)
: null;
const wordGapThreshold =
commonGap === null
? Infinity
: commonGap * (isGlyphRun ? LATIN_GLYPH_WORD_GAP_RATIO : LATIN_FRAGMENT_WORD_GAP_RATIO);
let text = parts[0]?.text ?? '';
for (let index = 1; index < parts.length; index += 1) {
const previous = parts[index - 1]!;
const current = parts[index]!;
const hasAuthoredSpace = /\s$/u.test(previous.text) || /^\s/u.test(current.text);
const normalizedGap = normalizedLatinFragmentGap(previous, current);
const measure = latinFragmentGapMeasure(previous, current);
const normalizedGap = measure === null ? null : measure.distance / measure.meanWeight;
// A capital into lowercase is almost always a capitalized word's own first letters
// (`S|miles`), and capitals overrun the width table too easily, so the excess rule
// never fires there. A lone capital word like `I` is narrow enough for the ratio
// test to catch its word gap on its own.
const hasAdvanceExcess =
isGlyphRun &&
commonGap !== null &&
normalizedGaps.length >= MIN_LATIN_GLYPH_EXCESS_GAP_SAMPLES &&
measure !== null &&
!(/^[A-Z]$/u.test(previous.text) && /^[a-z]$/u.test(current.text)) &&
measure.distance - measure.meanWeight * commonGap > LATIN_GLYPH_WORD_EXCESS_RATIO * commonGap;
const hasPositionedWordGap =
startsNewPositionedFragmentSequence(previous, current) ||
(normalizedGap !== null &&
normalizedGap > wordGapThreshold &&
(normalizedGap > wordGapThreshold || hasAdvanceExcess) &&
!isLikelyTwoGlyphCapitalizedWord({
parts,
index,
@@ -786,6 +827,55 @@ function reconstructAssFragmentLine(
};
}
// `\fnSplit splat splodge` tokenizes as name `fnSplit` + args `splat splodge`, while
// `\fnArial` is all name and `\fn04b` is all args, so the font is both pieces rejoined.
function staticFontOverride(cue: AnnotatedSubtitleCue): string | null {
let font: string | null = null;
for (const command of cue.overrides) {
if (command.animated || !command.name.toLowerCase().startsWith('fn')) continue;
font = [command.name.slice(2), command.args].filter(Boolean).join(' ').trim().toLowerCase();
}
return font;
}
/**
* Generated lyric effects often layer decoration over the real syllables: single letters
* positioned above each glyph, animated in, and rendered through a `\fn` override to a
* symbol font where `a` draws as a sparkle rather than a letter. Reading them as text
* corrupts the reconstructed line (`sotto mimi ni ateru to` gains a trailing `a z x`).
* Within one style/name group, a font used only for scattered animated single glyphs --
* while the group's actual text renders in another font -- marks those events as
* decoration rather than dialogue.
*/
function decorativeGlyphEvents(events: readonly AnnotatedSubtitleCue[]): Set<AnnotatedSubtitleCue> {
const byFont = new Map<string, AnnotatedSubtitleCue[]>();
for (const cue of events) {
const font = staticFontOverride(cue);
if (font === null) continue;
const group = byFont.get(font);
if (group) {
group.push(cue);
} else {
byFont.set(font, [cue]);
}
}
const decorative = new Set<AnnotatedSubtitleCue>();
for (const fontEvents of byFont.values()) {
if (fontEvents.length * 2 >= events.length) continue;
const allScatteredGlyphs = fontEvents.every(
(cue) =>
[...compactCueMatchText(cue)].length === 1 &&
fragmentPosition(cue) !== null &&
hasAssTemporalOverride(cue.overrides),
);
if (allScatteredGlyphs) {
fontEvents.forEach((cue) => decorative.add(cue));
}
}
return decorative;
}
function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
const groups = new Map<string, AnnotatedSubtitleCue[]>();
for (const cue of dialogue) {
@@ -804,13 +894,26 @@ function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): Annotate
const recovered: AnnotatedSubtitleCue[] = [];
const suppressed = new Set<AnnotatedSubtitleCue>();
for (const events of groups.values()) {
for (const cluster of clusterAssFragmentEvents(events)) {
const decorative = decorativeGlyphEvents(events);
const lineEvents = decorative.size
? events.filter((event) => !decorative.has(event))
: events;
for (const cluster of clusterAssFragmentEvents(lineEvents)) {
const line = reconstructAssFragmentLine(cluster.events);
if (!line) {
continue;
}
recovered.push(line);
cluster.events.forEach((event) => suppressed.add(event));
// Decoration is timed to the line it overlays, so it disappears with the line's
// full animation span. Decoration outside any recovered span stays published.
const spanStart = line.animationStartTime ?? line.startTime;
const spanEnd = line.animationEndTime ?? line.endTime;
for (const overlay of decorative) {
if (overlay.startTime < spanEnd && overlay.endTime > spanStart) {
suppressed.add(overlay);
}
}
}
}
if (recovered.length === 0) {