fix(subtitles): suppress ASS font texture artifacts

- Filter clipped glyph runs and alpha-texture effects from parsed ASS cues
- Add regression coverage for repeated-glyph and per-character texture signs
This commit is contained in:
2026-08-24 01:33:59 -07:00
parent 60432ca2f3
commit b029cc73a1
3 changed files with 101 additions and 2 deletions
@@ -1,4 +1,4 @@
type: fixed type: fixed
area: overlay area: overlay
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Fragmented ASS karaoke keeps spaces authored at event boundaries and recovers Latin word spaces encoded only by positioned fragment gaps, including word gaps measured across wide glyphs that width normalization alone reads as ordinary letter advances. Progressive karaoke highlights, offset shadow copies, and overlapping decorative glyphs remain suppressed. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become concatenated primary or secondary lines. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display. A failed source refresh also clears ASS-only cleanup so fallback text from other formats stays intact. - Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Fragmented ASS karaoke keeps spaces authored at event boundaries and recovers Latin word spaces encoded only by positioned fragment gaps, including word gaps measured across wide glyphs that width normalization alone reads as ordinary letter advances. Progressive karaoke highlights, offset shadow copies, overlapping decorative glyphs, and font-based sign textures remain suppressed. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become concatenated primary or secondary lines. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display. A failed source refresh also clears ASS-only cleanup so fallback text from other formats stays intact.
@@ -1250,6 +1250,41 @@ test('parseSubtitleCues drops symbol-font glyph decoration from a reconstructed
assert.equal(cues[0]?.text, 'sotto mimi ni ateru to'); 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 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 separates overlapping positioned English lyric sequences', () => { test('parseSubtitleCues separates overlapping positioned English lyric sequences', () => {
const fragments = [ const fragments = [
['my', 642, '0:00:01.00', '0:00:04.05'], ['my', 642, '0:00:01.00', '0:00:04.05'],
+65 -1
View File
@@ -964,6 +964,69 @@ function staticFontOverride(cue: AnnotatedSubtitleCue): string | null {
return font; return font;
} }
const MIN_TEXTURE_GLYPH_RUN = 8;
const MIN_TEXTURE_ALPHA_OVERRIDES = 6;
function hasStaticOverride(cue: AnnotatedSubtitleCue, expectedName: string): boolean {
return cue.overrides.some(
(command) => !command.animated && command.name.toLowerCase() === expectedName,
);
}
/**
* Some ASS signs feed placeholder glyphs through a texture font instead of drawing the
* texture as a vector. A long clipped single-glyph run or frequent changing secondary
* alpha tags identifies the effect without guessing from its visible text or font name.
*/
function isAssFontTextureSeed(cue: AnnotatedSubtitleCue): boolean {
if (staticFontOverride(cue) === null || fragmentPosition(cue) === null) {
return false;
}
const glyphs = [...compactCueMatchText(cue)];
const isClippedRepeatedGlyphRun =
glyphs.length >= MIN_TEXTURE_GLYPH_RUN &&
glyphs.every((glyph) => glyph === glyphs[0]) &&
(hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip'));
if (isClippedRepeatedGlyphRun) {
return true;
}
const secondaryAlpha = cue.overrides.filter(
(command) => !command.animated && command.name.toLowerCase() === '2a',
);
if (secondaryAlpha.length < MIN_TEXTURE_ALPHA_OVERRIDES) {
return false;
}
const alphaValues = secondaryAlpha.map((command) => command.args.toLowerCase());
return alphaValues.every((value, index) => index === 0 || value !== alphaValues[index - 1]);
}
function assFontTextureGroupKey(cue: AnnotatedSubtitleCue): string | null {
const font = staticFontOverride(cue);
return font === null
? null
: `${assEventGroupKey(cue)}\0${cue.startTime}\0${cue.endTime}\0${font}`;
}
function removeAssFontTextureEvents(events: ParsedAssEvents): ParsedAssEvents {
// Short pieces can share the seeded font effect without carrying enough tags to identify
// themselves. Limit propagation to the exact style, actor, time span, and font group.
const textureGroups = new Set(
events.dialogue
.filter(isAssFontTextureSeed)
.map(assFontTextureGroupKey)
.filter((key): key is string => key !== null),
);
return {
dialogue: events.dialogue.filter((cue) => {
const key = assFontTextureGroupKey(cue);
return key === null || !textureGroups.has(key);
}),
comments: events.comments,
};
}
/** /**
* Generated lyric effects often layer decoration over the real syllables: single letters * 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 * positioned above each glyph, animated in, and rendered through a `\fn` override to a
@@ -1434,7 +1497,8 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
} }
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] { function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(parseAnnotatedAssEvents(content))); const events = removeAssFontTextureEvents(parseAnnotatedAssEvents(content));
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events));
} }
export function parseAssCues(content: string): SubtitleCue[] { export function parseAssCues(content: string): SubtitleCue[] {