From 0b963ef72906679aebd46f4c71da5aafe9f3668b Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 24 Aug 2026 18:16:42 -0700 Subject: [PATCH] fix(subtitles): preserve opaque text beside ASS texture fragments - Require structural texture evidence before suppressing same-font fragments --- src/core/services/subtitle-cue-parser.test.ts | 15 +++++++++++++++ src/core/services/subtitle-cue-parser.ts | 16 ++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/core/services/subtitle-cue-parser.test.ts b/src/core/services/subtitle-cue-parser.test.ts index 870c5165..e4006949 100644 --- a/src/core/services/subtitle-cue-parser.test.ts +++ b/src/core/services/subtitle-cue-parser.test.ts @@ -1265,6 +1265,21 @@ test('parseSubtitleCues drops clipped repeated-glyph texture text', () => { ); }); +test('parseSubtitleCues preserves opaque same-font text beside texture fragments', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 2,0:00:01.00,0:00:04.00,MarySigns,seed,0,0,0,,{\\pos(960,80)\\fnSerangkaian Pattern Regular\\clip(800,20,1120,140)}LLLLLLLLLLLLLLLLLLLLLLLL', + 'Dialogue: 2,0:00:01.00,0:00:04.00,MarySigns,piece,0,0,0,,{\\pos(960,110)\\fnSerangkaian Pattern Regular\\clip(800,20,1120,140)}LLLL', + 'Dialogue: 3,0:00:01.00,0:00:04.00,MarySigns,label,0,0,0,,{\\pos(960,150)\\fnSerangkaian Pattern Regular}Keep this label', + ].join('\n'); + + assert.deepEqual( + parseSubtitleCues(content, 'test.ass').map((cue) => cue.text), + ['Keep this label'], + ); +}); + test('parseSubtitleCues drops clipped repeated-glyph texture text without a font override', () => { const content = [ '[Events]', diff --git a/src/core/services/subtitle-cue-parser.ts b/src/core/services/subtitle-cue-parser.ts index 19df41cd..2d75ab1b 100644 --- a/src/core/services/subtitle-cue-parser.ts +++ b/src/core/services/subtitle-cue-parser.ts @@ -1038,6 +1038,14 @@ function isNearlyTransparentPositionedText(cue: AnnotatedSubtitleCue): boolean { ); } +function hasAssTextureCandidateEvidence(cue: AnnotatedSubtitleCue): boolean { + return ( + isClippedRepeatedGlyphFragment(cue) || + isNearlyTransparentPositionedText(cue) || + hasStaticOverride(cue, '2a') + ); +} + function assFontTextureGroupKey(cue: AnnotatedSubtitleCue): string | null { const font = staticFontOverride(cue); return font === null ? null : `${cue.style}\0${cue.startTime}\0${cue.endTime}\0${font}`; @@ -1050,9 +1058,9 @@ function assTextureTimingGroupKey(cue: AnnotatedSubtitleCue): string { function removeAssFontTextureEvents(events: ParsedAssEvents): ParsedAssEvents { const seeds = events.dialogue.filter(isAssTextureSeed); const seedSet = new Set(seeds); - // Short pieces can share the seeded font effect under another actor without carrying - // enough tags to identify themselves. The exact style, time, and font group catches - // those pieces without inspecting their content. + // Short pieces can share the seeded font effect under another actor. Matching the + // seed's style, timing, and font only narrows the candidates; each piece must still + // carry structural texture evidence. const textureGroups = new Set( seeds.map(assFontTextureGroupKey).filter((key): key is string => key !== null), ); @@ -1090,7 +1098,7 @@ function removeAssFontTextureEvents(events: ParsedAssEvents): ParsedAssEvents { return false; } const key = assFontTextureGroupKey(cue); - if (key !== null && textureGroups.has(key)) { + if (key !== null && textureGroups.has(key) && hasAssTextureCandidateEvidence(cue)) { return false; } if (!isNearlyTransparentPositionedText(cue)) {