fix(subtitles): recover positioned word gaps in reconstructed translations

This commit is contained in:
2026-08-23 21:37:48 -07:00
parent 6d1a1b841a
commit 9044340676
3 changed files with 105 additions and 20 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
type: fixed
area: subtitles
- Typeset ASS karaoke and animated signs no longer flood the primary overlay, subtitle sidebar, immersion history, or sentence mining with repeated glyph fragments or full-line color phases. Matching timed comments and full-line boundary events recover the complete authored line without merging ordinary repeated dialogue or separately positioned signs, and dialogue spoken while a song's animation is on screen is kept intact instead of being replaced by the lyric. Entrance and exit frames that run past the authored line timing still resolve to the clean line during lyric transitions, and dialogue spoken while a song's animation is on screen enters immersion and subtitle history without the fragment lines beside it. Dense visual grids (sign walls, countdown frames, scattered glyph typesetting) stay out of the published text, while multi-row CC-style dialogue blocks and wrapped lyric rows are still published. Decorative letters that lyric effects render in symbol fonts over the syllables are dropped with the animation instead of corrupting the reconstructed line or leaking as stray cues. Karaoke highlight sweeps that repaint one syllable at a time over an already-visible lyric are suppressed instead of surfacing as rolling partial copies or lone flickering syllables beside the line, and drop-shadow glyph copies offset a few pixels from their base no longer double every syllable in the reconstructed lyric.
- Typeset ASS karaoke and animated signs no longer flood the primary overlay, subtitle sidebar, immersion history, or sentence mining with repeated glyph fragments or full-line color phases. Matching timed comments and full-line boundary events recover the complete authored line without merging ordinary repeated dialogue or separately positioned signs, and dialogue spoken while a song's animation is on screen is kept intact instead of being replaced by the lyric. Entrance and exit frames that run past the authored line timing still resolve to the clean line during lyric transitions, and dialogue spoken while a song's animation is on screen enters immersion and subtitle history without the fragment lines beside it. Dense visual grids (sign walls, countdown frames, scattered glyph typesetting) stay out of the published text, while multi-row CC-style dialogue blocks and wrapped lyric rows are still published. Decorative letters that lyric effects render in symbol fonts over the syllables are dropped with the animation instead of corrupting the reconstructed line or leaking as stray cues. Karaoke highlight sweeps that repaint one syllable at a time over an already-visible lyric are suppressed instead of surfacing as rolling partial copies or lone flickering syllables beside the line, and drop-shadow glyph copies offset a few pixels from their base no longer double every syllable in the reconstructed lyric. Positioned word gaps are also recovered on lines where a single fragment carries a literal space, and between wide syllable chunks whose word gap is hidden by their own width, so reconstructed translations keep their spacing instead of running words together.
- The secondary subtitle overlay drops layered duplicate lines from animated tracks, so a short stack of repeated words collapses to its distinct lines even when the full karaoke heuristic does not apply.
+79 -2
View File
@@ -1150,8 +1150,8 @@ 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,
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]',
@@ -1596,3 +1596,80 @@ test('parseSubtitleCues collapses drop-shadow layer copies offset by a few pixel
assert.equal(cues.length, 1);
assert.equal(cues[0]?.text.replace(/\s+/gu, ''), 'menomaeninobiru');
});
test('parseSubtitleCues recovers positional word gaps beside an authored space', () => {
// Real ED line: every glyph is placed by `\move`, but the `star` fragment alone carries
// a literal leading space. The authored space must not disable positional recovery for
// the rest of the line.
const fragments = [
['s', 633],
['e', 665],
['a', 697],
['r', 723],
['c', 747],
['h', 774],
['i', 793],
['n', 813],
['g', 838],
['f', 884],
['o', 911],
['r', 937],
['a', 986],
['s', 1041],
['h', 1070],
['o', 1098],
['o', 1128],
['t', 1153],
['i', 1169],
['n', 1188],
['g', 1214],
[' s', 1264],
['t', 1290],
['a', 1316],
['r', 1342],
] as const;
const content = [
...eventsHeader,
...[0, 1].flatMap((layer) =>
fragments.map(
([fragment, x], index) =>
`Dialogue: ${layer},0:22:44.83,0:22:47.70,ED English,,0,0,0,fx,{\\move(${x},1020,${x},1020,0,300)\\t(${index * 2},${index * 2 + 300},\\fs90)}${fragment}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'searching for a shooting star');
});
test('parseSubtitleCues splits chunked words whose gap only the excess rule catches', () => {
// `Choices|presumably` normalizes to just under the ratio threshold because both
// neighbors are wide three-letter chunks; its constant word-space excess still shows.
const fragments = [
['Ch', 526],
['oi', 584],
['ces', 648],
['pre', 747],
['su', 819],
['mab', 904],
['ly', 981],
['ma', 1059],
['de', 1128],
['by', 1203],
['cha', 1295],
['nce', 1385],
] as const;
const content = [
...eventsHeader,
...[0, 1].flatMap((layer) =>
fragments.map(
([fragment, x], index) =>
`Dialogue: ${layer},0:01:53.01,0:01:55.52,OP English,,0,0,0,fx,{\\pos(${x},1055)\\t(${index * 2},${index * 2 + 120},\\blur0.5)}${fragment}`,
),
),
].join('\n');
assert.equal(
parseSubtitleCues(content, 'test.ass')[0]?.text,
'Choices presumably made by chance',
);
});
+25 -17
View File
@@ -440,6 +440,12 @@ const LATIN_GLYPH_WORD_GAP_RATIO = 1.4;
// 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;
// Multi-character syllable chunks average out proportional-font variation, so their
// advances track the width model far more closely than single glyphs do. Measured on a
// chunked lyric line, within-word excess stayed under 0.07 of the common unit while every
// word gap cleared 0.31, so a tighter margin separates them without splitting words.
const LATIN_CHUNK_WORD_EXCESS_RATIO = 0.2;
const MIN_LATIN_CHUNK_EXCESS_GAP_SAMPLES = 6;
const LATIN_TWO_GLYPH_WORD_NEXT_GAP_RATIO = 1.2;
function fragmentPosition(cue: AnnotatedSubtitleCue): AssFragmentPosition | null {
@@ -581,19 +587,20 @@ function isLikelyTwoGlyphCapitalizedWord(options: {
* 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.
* The ratio test alone under-detects a word gap next to a wide fragment (`waves within`
* measured across `s`/`w`, or `Choices presumably` across two three-letter chunks, both
* normalize to nearly a common advance), so a gap also counts 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. Chunk runs use a tighter margin than per-glyph runs because their
* advances deviate less from the width model.
*
* A line may mix both conventions: one fragment carrying a literal space while its
* neighbors rely on position alone. Whitespace-bearing fragments have no width weight, so
* they drop out of the estimate and their own boundary comes from the authored space,
* leaving the surrounding positional gaps to be recovered normally.
*/
function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string {
if (parts.some((part) => /\s/u.test(part.text))) {
return parts
.map((part) => part.text)
.join('')
.trim();
}
const normalizedGaps: number[] = [];
for (let index = 1; index < parts.length; index += 1) {
const gap = normalizedLatinFragmentGap(parts[index - 1]!, parts[index]!);
@@ -620,13 +627,16 @@ function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string {
// (`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 excessRatio = isGlyphRun ? LATIN_GLYPH_WORD_EXCESS_RATIO : LATIN_CHUNK_WORD_EXCESS_RATIO;
const minimumExcessSamples = isGlyphRun
? MIN_LATIN_GLYPH_EXCESS_GAP_SAMPLES
: MIN_LATIN_CHUNK_EXCESS_GAP_SAMPLES;
const hasAdvanceExcess =
isGlyphRun &&
commonGap !== null &&
normalizedGaps.length >= MIN_LATIN_GLYPH_EXCESS_GAP_SAMPLES &&
normalizedGaps.length >= minimumExcessSamples &&
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;
measure.distance - measure.meanWeight * commonGap > excessRatio * commonGap;
const hasPositionedWordGap =
startsNewPositionedFragmentSequence(previous, current) ||
(normalizedGap !== null &&
@@ -1005,9 +1015,7 @@ function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): Annotate
const suppressed = new Set<AnnotatedSubtitleCue>();
for (const events of groups.values()) {
const decorative = decorativeGlyphEvents(events);
const lineEvents = decorative.size
? events.filter((event) => !decorative.has(event))
: events;
const lineEvents = decorative.size ? events.filter((event) => !decorative.has(event)) : events;
if (isProgressiveHighlightSweepGroup(lineEvents)) {
lineEvents.forEach((event) => suppressed.add(event));
continue;