fix(subtitles): suppress ASS texture layers and advance canonical lyrics

- Remove clipped and transparent ASS texture fragments
- Show canonical lyrics when their animations begin
This commit is contained in:
2026-08-24 02:59:39 -07:00
parent b029cc73a1
commit b7358507b1
6 changed files with 223 additions and 37 deletions
+75 -13
View File
@@ -1265,6 +1265,21 @@ test('parseSubtitleCues drops clipped repeated-glyph texture text', () => {
);
});
test('parseSubtitleCues drops clipped repeated-glyph texture text without a font override', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\an7\\pos(736.49,152.99)\\fscy150\\fs10\\bord3\\c&H657BC8&\\3c&H657BC8&\\blur3\\clip}lllllllllllll',
'Dialogue: 0,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\an7\\pos(769.9,106.18)\\fscy150\\fs12\\bord3\\c&H66729F&\\3c&H66729F&\\blur5\\clip}llll',
'Dialogue: 5,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(893,311)}Read',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
['Read'],
);
});
test('parseSubtitleCues drops per-character alpha texture text', () => {
const content = [
'[Events]',
@@ -1285,6 +1300,53 @@ test('parseSubtitleCues drops per-character alpha texture text', () => {
);
});
test('parseSubtitleCues drops transparent texture payloads across an animated sign', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
"Dialogue: 90,0:00:01.00,0:00:04.00,Alt,,0,0,0,,Even if you want to see her, she doesn't want to see you!",
'Dialogue: 0,0:00:01.00,0:00:01.08,FrogSigns,,0,0,0,,{\\pos(699,803)\\fnSerangkaian Pattern Regular\\clip(300,380,1130,1050)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a0}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\\\\\\\\\\\\\\\\\\\\\',
'Dialogue: 3,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnGrain\\alpha&HE0&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
'Dialogue: 5,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnRoboto Medium\\alpha&H00&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
'Dialogue: 6,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnGrain\\alpha&HE0&}H1.4igcAhGYHVWD"kHcVlG2W9eKEWj"!X\\N\'uNVaEVpTXMd9rk7dnRX\'P!RhsS"Wn90k6',
'Dialogue: 6,0:00:01.00,0:00:01.08,FrogSigns,18K,0,0,0,,{\\pos(284,821)\\fnGrain\\alpha&HE0&}ou:QepiiPqQ.4n.IYbFaGHtPzWyKI9CUSq:',
'Dialogue: 1,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(581,921)\\fnSerangkaian Pattern Regular\\clip(195,495,986,1120)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{',
'Dialogue: 3,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnGrain\\alpha&HE0&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
'Dialogue: 5,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnRoboto Medium\\alpha&H00&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
'Dialogue: 3,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnGrain\\alpha&HF0&}9LF\'GpPCTlOkLxBLV:QN,8R8NUVM"ha.s\\NNUUPNTBdJih4jUthK34i,yYe;9EBgLXbET',
"Dialogue: 6,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(150,936)\\fnGrain\\alpha&HE0&}JS7vl:lD;'PzkCb!bGT;.7TbA.KCkEH0LOk",
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
[
'Street performance by Mortis from\nMujica - Acting prodigy in action!',
"Even if you want to see her, she doesn't want to see you!",
'Street performance by Mortis from\nMujica - Acting prodigy in action!',
],
);
});
test('parseSubtitleCues does not reconstruct short texture pieces under another actor', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,bubble,0,0,0,,{\\pos(245,-102)\\fnSerangkaian Pattern Regular\\clip(224,-1,831,106)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L',
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(917,293)\\alpha&H20&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(911,293)\\alpha&H58&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(845,300)\\alpha&H00&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(907,293)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,289,1010,338)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(911,293)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,289,1010,338)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(922,130)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,120,1010,173)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 5,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(893,311)\\fnSFProDisplay-Regular-STR}Read 3',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
['Read 3'],
);
});
test('parseSubtitleCues separates overlapping positioned English lyric sequences', () => {
const fragments = [
['my', 642, '0:00:01.00', '0:00:04.05'],
@@ -1382,25 +1444,25 @@ test('parseSubtitleCues marks a repeated-token sign wall as a fragment grid', ()
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.assLayout?.kind, 'fragment-grid');
});
test('parseSubtitleCues keeps a wrapped lyric with repeated syllables publishable', () => {
test('parseSubtitleCues keeps a wrapped lyric with a staggered repeated token 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],
['dreams', 300, 115, '0:00:01.00'],
['ju', 250, 39, '0:00:01.00'],
['n', 280, 39, '0:00:01.00'],
['jo', 300, 39, '0:00:01.00'],
['u', 330, 39, '0:00:01.00'],
['to', 360, 39, '0:00:01.00'],
['jo', 395, 39, '0:00:01.02'],
['u', 425, 39, '0:00:01.00'],
['ne', 455, 39, '0:00:01.00'],
['tsu!', 485, 39, '0:00:01.00'],
] 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}`,
([text, x, y, start], index) =>
`Dialogue: ${layer},${start},0:00:04.00,ED Romaji,,0,0,0,,{\\pos(${x},${y})\\t(${index * 2},${index * 2 + 100},\\fscx120)}${text}`,
),
),
].join('\n');
+100 -21
View File
@@ -679,8 +679,8 @@ function looksLikeFragmentGridParts(parts: readonly AssFragmentPart[]): boolean
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)}`;
if (part.layout?.kind !== 'positioned' || !part.position) continue;
const placement = `${part.text}@${Math.round(part.position.x)},${Math.round(part.position.y)}`;
const earlierStart = seenPlacements.get(placement);
if (earlierStart !== undefined && Math.abs(part.startTime - earlierStart) > 0.01) {
return true;
@@ -966,6 +966,8 @@ function staticFontOverride(cue: AnnotatedSubtitleCue): string | null {
const MIN_TEXTURE_GLYPH_RUN = 8;
const MIN_TEXTURE_ALPHA_OVERRIDES = 6;
const MIN_TEXTURE_LAYER_ALPHA = 0xe0;
const ASS_ALPHA_VALUE_PATTERN = /^&?H([0-9a-f]{1,2})&?$/iu;
function hasStaticOverride(cue: AnnotatedSubtitleCue, expectedName: string): boolean {
return cue.overrides.some(
@@ -973,25 +975,36 @@ function hasStaticOverride(cue: AnnotatedSubtitleCue, expectedName: string): boo
);
}
function isClippedRepeatedGlyphFragment(cue: AnnotatedSubtitleCue): boolean {
const glyphs = [...compactCueMatchText(cue)];
return (
glyphs.length > 0 &&
glyphs.every((glyph) => glyph === glyphs[0]) &&
(hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip'))
);
}
/**
* 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.
* Some ASS signs build image textures from clipped placeholder glyphs, optionally through
* a texture font. 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) {
function isAssTextureSeed(cue: AnnotatedSubtitleCue): boolean {
if (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'));
glyphs.length >= MIN_TEXTURE_GLYPH_RUN && isClippedRepeatedGlyphFragment(cue);
if (isClippedRepeatedGlyphRun) {
return true;
}
if (staticFontOverride(cue) === null) {
return false;
}
const secondaryAlpha = cue.overrides.filter(
(command) => !command.animated && command.name.toLowerCase() === '2a',
);
@@ -999,29 +1012,95 @@ function isAssFontTextureSeed(cue: AnnotatedSubtitleCue): boolean {
return false;
}
const alphaValues = secondaryAlpha.map((command) => command.args.toLowerCase());
return alphaValues.every((value, index) => index === 0 || value !== alphaValues[index - 1]);
return new Set(alphaValues).size >= 2;
}
function staticGlobalAlpha(cue: AnnotatedSubtitleCue): number | null {
let alpha: number | null = null;
for (const command of cue.overrides) {
if (command.animated || command.name.toLowerCase() !== 'alpha') continue;
const match = ASS_ALPHA_VALUE_PATTERN.exec(command.args.trim());
const alphaValue = match?.[1];
if (alphaValue !== undefined) {
alpha = Number.parseInt(alphaValue, 16);
}
}
return alpha;
}
function isNearlyTransparentPositionedText(cue: AnnotatedSubtitleCue): boolean {
const alpha = staticGlobalAlpha(cue);
return (
alpha !== null &&
alpha >= MIN_TEXTURE_LAYER_ALPHA &&
staticFontOverride(cue) !== null &&
fragmentPosition(cue) !== null
);
}
function assFontTextureGroupKey(cue: AnnotatedSubtitleCue): string | null {
const font = staticFontOverride(cue);
return font === null
? null
: `${assEventGroupKey(cue)}\0${cue.startTime}\0${cue.endTime}\0${font}`;
return font === null ? null : `${cue.style}\0${cue.startTime}\0${cue.endTime}\0${font}`;
}
function assTextureTimingGroupKey(cue: AnnotatedSubtitleCue): string {
return `${cue.style}\0${cue.startTime}\0${cue.endTime}`;
}
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 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.
const textureGroups = new Set(
events.dialogue
.filter(isAssFontTextureSeed)
.map(assFontTextureGroupKey)
.filter((key): key is string => key !== null),
seeds.map(assFontTextureGroupKey).filter((key): key is string => key !== null),
);
const noFontTextureTimings = new Set(
seeds
.filter((seed) => staticFontOverride(seed) === null)
.map((seed) => assTextureTimingGroupKey(seed)),
);
// Some signs switch actor and font between the texture mask and its payload. A nearly
// transparent text event that overlaps a proven seed in the same style is another input
// to that visual effect. Opaque authored text in the same sign remains publishable.
const seedsByStyle = new Map<string, AnnotatedSubtitleCue[]>();
for (const seed of seeds) {
const styleSeeds = seedsByStyle.get(seed.style);
if (styleSeeds) {
styleSeeds.push(seed);
} else {
seedsByStyle.set(seed.style, [seed]);
}
}
const seedIndexesByStyle = new Map(
[...seedsByStyle].map(([style, styleSeeds]) => [style, buildAssEventGroupIndex(styleSeeds)]),
);
return {
dialogue: events.dialogue.filter((cue) => {
if (seedSet.has(cue)) {
return false;
}
if (
staticFontOverride(cue) === null &&
noFontTextureTimings.has(assTextureTimingGroupKey(cue)) &&
isClippedRepeatedGlyphFragment(cue)
) {
return false;
}
const key = assFontTextureGroupKey(cue);
return key === null || !textureGroups.has(key);
if (key !== null && textureGroups.has(key)) {
return false;
}
if (!isNearlyTransparentPositionedText(cue)) {
return true;
}
const styleSeedIndex = seedIndexesByStyle.get(cue.style);
return (
styleSeedIndex === undefined ||
eventsOverlappingWindow(styleSeedIndex, cue.startTime, cue.endTime).length === 0
);
}),
comments: events.comments,
};