mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-25 12:15:26 -07:00
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:
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user