mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-25 12:15:26 -07:00
fix(subtitles): drop symbol-font glyph decoration and recover wide-glyph word gaps
Generated lyric effects can overlay each syllable with animated single letters
rendered through \fn in a symbol font, where ordinary letters draw as sparkles.
Reading them as text corrupted reconstructed lines ("sotto mimi ni ateru to a z
x") and leaked junk cues ("hlk"). A font a style group uses only for scattered
animated single glyphs now marks those events as decoration: they stay out of
fragment reconstruction and are suppressed alongside the line they overlay.
Per-glyph word gaps measured across a wide glyph ("waves|within" over s/w)
normalize to nearly a common advance, so the ratio test missed them. A word
space adds a roughly constant extra distance regardless of neighbor widths, so
glyph runs with enough gap samples also split when the advance exceeds the
width-predicted advance by a material fraction of the line's common unit.
Capital-to-lowercase pairs and short sample counts are excluded; both guards
are pinned by corpus-derived regression tests.
Across the 145-file library corpus this removes every scattered-letter
malformation and recovers 30+ missing word spaces with no other output change.
This commit is contained in:
@@ -1143,6 +1143,113 @@ test('parseSubtitleCues keeps a short capitalized word when the following gap is
|
||||
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'If I grow');
|
||||
});
|
||||
|
||||
// Geometry taken from a real per-glyph ED line. The `waves within` gap crosses a wide
|
||||
// `w`, so the width-normalized ratio reads it as a common advance; only the constant
|
||||
// extra distance of the authored word space gives it away.
|
||||
test('parseSubtitleCues recovers a word gap measured across a wide glyph', () => {
|
||||
const text = 'youcanhearthesoundofthewaveswithinmyheart';
|
||||
const positions = [
|
||||
202, 223, 244, 274, 296, 317, 346, 367, 389, 408, 433, 450, 470, 499, 517, 538, 557, 578, 609,
|
||||
627, 651, 668, 688, 723, 748, 770, 792, 811, 843, 863, 875, 892, 907, 922, 957, 983, 1013,
|
||||
1034, 1055, 1075, 1090,
|
||||
];
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
...[0, 1].flatMap((layer) =>
|
||||
[...text].map(
|
||||
(glyph, index) =>
|
||||
`Dialogue: ${layer},0:00:01.00,0:00:04.00,ED English,,0,0,0,,{\\pos(${positions[index]},687)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
|
||||
),
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
assert.equal(
|
||||
parseSubtitleCues(content, 'test.ass')[0]?.text,
|
||||
'you can hear the sound of the waves within my heart',
|
||||
);
|
||||
});
|
||||
|
||||
// A single short word gives too few gap samples to trust the excess rule: its narrow
|
||||
// glyphs skew the common advance low and `w e` would read as a word gap.
|
||||
test('parseSubtitleCues does not split a short single positioned word', () => {
|
||||
const text = 'Swelling';
|
||||
const positions = [592, 613, 635, 647, 655, 662, 673, 689];
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
...[0, 1].flatMap((layer) =>
|
||||
[...text].map(
|
||||
(glyph, index) =>
|
||||
`Dialogue: ${layer},0:00:01.00,0:00:04.00,ED English,,0,0,0,,{\\pos(${positions[index]},682)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
|
||||
),
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'Swelling');
|
||||
});
|
||||
|
||||
// A capitalized word whose first letter sits before a wide glyph (`S|miles`) overruns
|
||||
// the width table; the excess rule must not split a capital from its lowercase run.
|
||||
test('parseSubtitleCues keeps a capitalized word intact under the excess rule', () => {
|
||||
const text = 'Smilesarebudding';
|
||||
const positions = [37, 63, 80, 88, 99, 113, 142, 157, 171, 201, 217, 235, 255, 269, 280, 295];
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
...[0, 1].flatMap((layer) =>
|
||||
[...text].map(
|
||||
(glyph, index) =>
|
||||
`Dialogue: ${layer},0:00:01.00,0:00:04.00,ED English,,0,0,0,,{\\pos(${positions[index]},682)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
|
||||
),
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'Smiles are budding');
|
||||
});
|
||||
|
||||
// Mirrors a real ED: per-syllable romaji at y=34 overlaid with animated single letters
|
||||
// at y=29 rendered through `\fn` in a symbol font, where `a` draws as a sparkle. The
|
||||
// letters must neither join the reconstructed line nor survive as their own cues.
|
||||
test('parseSubtitleCues drops symbol-font glyph decoration from a reconstructed line', () => {
|
||||
const syllables = [
|
||||
['so', 479],
|
||||
['t', 505],
|
||||
['to', 529],
|
||||
['mi', 577],
|
||||
['mi', 618],
|
||||
['ni', 663],
|
||||
['a', 699],
|
||||
['te', 728],
|
||||
['ru', 764],
|
||||
['to', 810],
|
||||
] as const;
|
||||
const decoration = [
|
||||
['a', 479, '0:00:01.25'],
|
||||
['z', 577, '0:00:02.51'],
|
||||
['x', 618, '0:00:02.78'],
|
||||
['q', 505, '0:00:04.20'],
|
||||
] as const;
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
...[0, 1].flatMap((layer) =>
|
||||
syllables.map(
|
||||
([syllable, x], index) =>
|
||||
`Dialogue: ${layer},0:00:01.00,0:00:05.37,ED Romaji,,0,0,0,fx,{\\an5\\pos(${x},34)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${syllable}`,
|
||||
),
|
||||
),
|
||||
...decoration.map(
|
||||
([glyph, x, start]) =>
|
||||
`Dialogue: 0,${start},0:00:05.37,ED Romaji,,0,0,0,fx,{\\pos(${x},29)\\fnSplit splat splodge\\fs28\\t(3870,3970,\\fscx105)}${glyph}`,
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
assert.equal(cues.length, 1);
|
||||
assert.equal(cues[0]?.text, 'sotto mimi ni ateru to');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues separates overlapping positioned English lyric sequences', () => {
|
||||
const fragments = [
|
||||
['my', 642, '0:00:01.00', '0:00:04.05'],
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
assOverrideSignature,
|
||||
assToPlainText,
|
||||
collectAssOverrideCommands,
|
||||
hasAssTemporalOverride,
|
||||
parseAssEffectField,
|
||||
removeAssControlDebrisLines,
|
||||
type AssEffectKind,
|
||||
@@ -400,6 +401,13 @@ interface AssFragmentPosition {
|
||||
const MIN_LATIN_POSITION_GAP_SAMPLES = 4;
|
||||
const LATIN_FRAGMENT_WORD_GAP_RATIO = 1.16;
|
||||
const LATIN_GLYPH_WORD_GAP_RATIO = 1.4;
|
||||
// Word-space advance beyond the width-predicted glyph advance, as a fraction of the
|
||||
// line's common unit. Measured corpus extremes: widest within-word excess 0.32 (`pp`
|
||||
// with tracking), narrowest word gap 0.40 (`s w` across a wide glyph). That margin only
|
||||
// holds when the common unit is estimated from enough glyph pairs; a short single-word
|
||||
// line (`Swelling`) skews the unit low and its ordinary advances read as word gaps.
|
||||
const LATIN_GLYPH_WORD_EXCESS_RATIO = 0.36;
|
||||
const MIN_LATIN_GLYPH_EXCESS_GAP_SAMPLES = 10;
|
||||
const LATIN_TWO_GLYPH_WORD_NEXT_GAP_RATIO = 1.2;
|
||||
|
||||
function fragmentPosition(cue: AnnotatedSubtitleCue): AssFragmentPosition | null {
|
||||
@@ -448,10 +456,15 @@ function isSingleLatinGlyphFragment(text: string): boolean {
|
||||
return [...text].filter((glyph) => /[A-Za-z0-9]/u.test(glyph)).length <= 1;
|
||||
}
|
||||
|
||||
function normalizedLatinFragmentGap(
|
||||
interface LatinFragmentGapMeasure {
|
||||
distance: number;
|
||||
meanWeight: number;
|
||||
}
|
||||
|
||||
function latinFragmentGapMeasure(
|
||||
previous: AssFragmentPart,
|
||||
current: AssFragmentPart,
|
||||
): number | null {
|
||||
): LatinFragmentGapMeasure | null {
|
||||
const previousWeight = latinFragmentWidthWeight(previous.text);
|
||||
const currentWeight = latinFragmentWidthWeight(current.text);
|
||||
const previousPosition = fragmentPosition(previous.cue);
|
||||
@@ -466,8 +479,16 @@ function normalizedLatinFragmentGap(
|
||||
// A wrapped authored line can return to the left on its next visual row. Preserve
|
||||
// that measured row transition as a separator without treating backwards movement
|
||||
// on the same row as a word gap.
|
||||
const positionDistance = yDistance <= 2 ? xDistance : Math.abs(xDistance) + yDistance;
|
||||
return positionDistance / ((previousWeight + currentWeight) / 2);
|
||||
const distance = yDistance <= 2 ? xDistance : Math.abs(xDistance) + yDistance;
|
||||
return { distance, meanWeight: (previousWeight + currentWeight) / 2 };
|
||||
}
|
||||
|
||||
function normalizedLatinFragmentGap(
|
||||
previous: AssFragmentPart,
|
||||
current: AssFragmentPart,
|
||||
): number | null {
|
||||
const measure = latinFragmentGapMeasure(previous, current);
|
||||
return measure === null ? null : measure.distance / measure.meanWeight;
|
||||
}
|
||||
|
||||
function startsNewPositionedFragmentSequence(
|
||||
@@ -527,6 +548,12 @@ function isLikelyTwoGlyphCapitalizedWord(options: {
|
||||
* Normalizing each gap by the neighboring fragment widths supports both single glyphs
|
||||
* and multi-character karaoke syllables without guessing from the text itself. Per-glyph
|
||||
* runs use a wider safety margin because proportional fonts vary more than syllable chunks.
|
||||
*
|
||||
* The ratio test alone under-detects a word gap next to a wide glyph (`waves within`
|
||||
* measured across `s`/`w` normalizes to nearly a common advance), so per-glyph runs also
|
||||
* treat a gap as a word boundary when its advance exceeds the width-predicted advance by
|
||||
* a material fraction of the line's common unit -- a word space adds a roughly constant
|
||||
* extra distance no matter how wide its neighbors are.
|
||||
*/
|
||||
function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string {
|
||||
if (parts.some((part) => /\s/u.test(part.text))) {
|
||||
@@ -540,24 +567,38 @@ function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string {
|
||||
const gap = normalizedLatinFragmentGap(parts[index - 1]!, parts[index]!);
|
||||
if (gap !== null) normalizedGaps.push(gap);
|
||||
}
|
||||
const wordGapThreshold =
|
||||
const isGlyphRun = parts.every((part) => isSingleLatinGlyphFragment(part.text));
|
||||
const commonGap =
|
||||
normalizedGaps.length >= MIN_LATIN_POSITION_GAP_SAMPLES
|
||||
? commonLatinFragmentGap(normalizedGaps) *
|
||||
(parts.every((part) => isSingleLatinGlyphFragment(part.text))
|
||||
? LATIN_GLYPH_WORD_GAP_RATIO
|
||||
: LATIN_FRAGMENT_WORD_GAP_RATIO)
|
||||
: Infinity;
|
||||
? commonLatinFragmentGap(normalizedGaps)
|
||||
: null;
|
||||
const wordGapThreshold =
|
||||
commonGap === null
|
||||
? Infinity
|
||||
: commonGap * (isGlyphRun ? LATIN_GLYPH_WORD_GAP_RATIO : LATIN_FRAGMENT_WORD_GAP_RATIO);
|
||||
|
||||
let text = parts[0]?.text ?? '';
|
||||
for (let index = 1; index < parts.length; index += 1) {
|
||||
const previous = parts[index - 1]!;
|
||||
const current = parts[index]!;
|
||||
const hasAuthoredSpace = /\s$/u.test(previous.text) || /^\s/u.test(current.text);
|
||||
const normalizedGap = normalizedLatinFragmentGap(previous, current);
|
||||
const measure = latinFragmentGapMeasure(previous, current);
|
||||
const normalizedGap = measure === null ? null : measure.distance / measure.meanWeight;
|
||||
// A capital into lowercase is almost always a capitalized word's own first letters
|
||||
// (`S|miles`), and capitals overrun the width table too easily, so the excess rule
|
||||
// never fires there. A lone capital word like `I` is narrow enough for the ratio
|
||||
// test to catch its word gap on its own.
|
||||
const hasAdvanceExcess =
|
||||
isGlyphRun &&
|
||||
commonGap !== null &&
|
||||
normalizedGaps.length >= MIN_LATIN_GLYPH_EXCESS_GAP_SAMPLES &&
|
||||
measure !== null &&
|
||||
!(/^[A-Z]$/u.test(previous.text) && /^[a-z]$/u.test(current.text)) &&
|
||||
measure.distance - measure.meanWeight * commonGap > LATIN_GLYPH_WORD_EXCESS_RATIO * commonGap;
|
||||
const hasPositionedWordGap =
|
||||
startsNewPositionedFragmentSequence(previous, current) ||
|
||||
(normalizedGap !== null &&
|
||||
normalizedGap > wordGapThreshold &&
|
||||
(normalizedGap > wordGapThreshold || hasAdvanceExcess) &&
|
||||
!isLikelyTwoGlyphCapitalizedWord({
|
||||
parts,
|
||||
index,
|
||||
@@ -786,6 +827,55 @@ function reconstructAssFragmentLine(
|
||||
};
|
||||
}
|
||||
|
||||
// `\fnSplit splat splodge` tokenizes as name `fnSplit` + args `splat splodge`, while
|
||||
// `\fnArial` is all name and `\fn04b` is all args, so the font is both pieces rejoined.
|
||||
function staticFontOverride(cue: AnnotatedSubtitleCue): string | null {
|
||||
let font: string | null = null;
|
||||
for (const command of cue.overrides) {
|
||||
if (command.animated || !command.name.toLowerCase().startsWith('fn')) continue;
|
||||
font = [command.name.slice(2), command.args].filter(Boolean).join(' ').trim().toLowerCase();
|
||||
}
|
||||
return font;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* symbol font where `a` draws as a sparkle rather than a letter. Reading them as text
|
||||
* corrupts the reconstructed line (`sotto mimi ni ateru to` gains a trailing `a z x`).
|
||||
* Within one style/name group, a font used only for scattered animated single glyphs --
|
||||
* while the group's actual text renders in another font -- marks those events as
|
||||
* decoration rather than dialogue.
|
||||
*/
|
||||
function decorativeGlyphEvents(events: readonly AnnotatedSubtitleCue[]): Set<AnnotatedSubtitleCue> {
|
||||
const byFont = new Map<string, AnnotatedSubtitleCue[]>();
|
||||
for (const cue of events) {
|
||||
const font = staticFontOverride(cue);
|
||||
if (font === null) continue;
|
||||
const group = byFont.get(font);
|
||||
if (group) {
|
||||
group.push(cue);
|
||||
} else {
|
||||
byFont.set(font, [cue]);
|
||||
}
|
||||
}
|
||||
|
||||
const decorative = new Set<AnnotatedSubtitleCue>();
|
||||
for (const fontEvents of byFont.values()) {
|
||||
if (fontEvents.length * 2 >= events.length) continue;
|
||||
const allScatteredGlyphs = fontEvents.every(
|
||||
(cue) =>
|
||||
[...compactCueMatchText(cue)].length === 1 &&
|
||||
fragmentPosition(cue) !== null &&
|
||||
hasAssTemporalOverride(cue.overrides),
|
||||
);
|
||||
if (allScatteredGlyphs) {
|
||||
fontEvents.forEach((cue) => decorative.add(cue));
|
||||
}
|
||||
}
|
||||
return decorative;
|
||||
}
|
||||
|
||||
function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
|
||||
const groups = new Map<string, AnnotatedSubtitleCue[]>();
|
||||
for (const cue of dialogue) {
|
||||
@@ -804,13 +894,26 @@ function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): Annotate
|
||||
const recovered: AnnotatedSubtitleCue[] = [];
|
||||
const suppressed = new Set<AnnotatedSubtitleCue>();
|
||||
for (const events of groups.values()) {
|
||||
for (const cluster of clusterAssFragmentEvents(events)) {
|
||||
const decorative = decorativeGlyphEvents(events);
|
||||
const lineEvents = decorative.size
|
||||
? events.filter((event) => !decorative.has(event))
|
||||
: events;
|
||||
for (const cluster of clusterAssFragmentEvents(lineEvents)) {
|
||||
const line = reconstructAssFragmentLine(cluster.events);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
recovered.push(line);
|
||||
cluster.events.forEach((event) => suppressed.add(event));
|
||||
// Decoration is timed to the line it overlays, so it disappears with the line's
|
||||
// full animation span. Decoration outside any recovered span stays published.
|
||||
const spanStart = line.animationStartTime ?? line.startTime;
|
||||
const spanEnd = line.animationEndTime ?? line.endTime;
|
||||
for (const overlay of decorative) {
|
||||
if (overlay.startTime < spanEnd && overlay.endTime > spanStart) {
|
||||
suppressed.add(overlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (recovered.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user