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
@@ -1250,6 +1250,41 @@ test('parseSubtitleCues drops symbol-font glyph decoration from a reconstructed
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', () => {
const fragments = [
['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;
}
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
* 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[] {
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(parseAnnotatedAssEvents(content)));
const events = removeAssFontTextureEvents(parseAnnotatedAssEvents(content));
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events));
}
export function parseAssCues(content: string): SubtitleCue[] {