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