fix(subtitles): suppress karaoke highlight sweeps from reconstructed lyrics

This commit is contained in:
2026-08-23 21:00:43 -07:00
parent 4635bfb264
commit 0ac5db1c92
3 changed files with 79 additions and 2 deletions
@@ -1530,3 +1530,43 @@ test('parseSubtitleCues recovers clear word gaps in a short romaji line', () =>
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'boku wo yobu');
});
test('parseSubtitleCues suppresses a karaoke highlight sweep without publishing it', () => {
// Main lyric: per-glyph fragments alive together for the whole line.
const lineFragments = [
['to', 972],
['so', 1051],
['u', 1113],
['o', 1166],
['mo', 1204],
] as const;
// Highlight sweep: one syllable at a time over the same lyric, each event ending
// exactly as the next begins, so no two syllables are ever on screen together.
const sweepFragments = [
['to', 972, '0:00:01.00', '0:00:01.40'],
['so', 1051, '0:00:01.40', '0:00:01.80'],
['u', 1113, '0:00:01.80', '0:00:02.20'],
['o', 1166, '0:00:02.20', '0:00:02.60'],
['mo', 1204, '0:00:02.60', '0:00:03.00'],
] as const;
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
lineFragments.map(
([fragment, x], index) =>
`Dialogue: ${layer},0:00:01.00,0:00:04.00,ED Romaji,,0,0,0,fx,{\\pos(${x},60)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`,
),
),
...[40, 41].flatMap((layer) =>
sweepFragments.map(
([fragment, x, start, end]) =>
`Dialogue: ${layer},${start},${end},ED Romaji2,,0,0,0,fx,{\\an5\\pos(${x},60)\\t(150,290,\\1a&HFF&)}${fragment}`,
),
),
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 1);
assert.equal(cues[0]?.text.replace(/\s+/gu, ''), 'tosouomo');
});
+38 -1
View File
@@ -762,6 +762,37 @@ function clusterAssFragmentEvents(
return clusters;
}
/**
* A karaoke highlight sweep repaints one syllable at a time over an already-visible
* lyric line: each event ends as the next begins, so the cluster's concatenated text is
* never on screen as a whole. Publishing it would emit rolling partial copies of the
* lyric ("to sou omo" beside "akenakute ii to sou omotteta"). Layer copies share one
* placement and timing, so the test is whether any two distinct placements coexist.
*/
function isProgressiveHighlightSweep(events: readonly AnnotatedSubtitleCue[]): boolean {
const intervals: { startTime: number; endTime: number }[] = [];
const seen = new Set<string>();
for (const event of events) {
const anchors = [...fragmentPlacementAnchors(event)].sort().join('|');
const key = `${compactCueMatchText(event)}\0${anchors}\0${event.startTime}\0${event.endTime}`;
if (seen.has(key)) continue;
seen.add(key);
intervals.push({ startTime: event.startTime, endTime: event.endTime });
}
if (intervals.length < 2) {
return false;
}
intervals.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime);
let latestEnd = intervals[0]!.endTime;
for (let index = 1; index < intervals.length; index += 1) {
if (intervals[index]!.startTime < latestEnd - 0.001) {
return false;
}
latestEnd = Math.max(latestEnd, intervals[index]!.endTime);
}
return true;
}
function decodeSingleAssFragment(cue: AnnotatedSubtitleCue): string | null {
const visibleLines = decodeSubtitleCueText(cue.rawText)
.split('\n')
@@ -903,6 +934,12 @@ function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): Annotate
if (!line) {
continue;
}
// A sweep only re-highlights the lyric it decorates: hide its events without
// publishing the reconstruction.
if (isProgressiveHighlightSweep(cluster.events)) {
cluster.events.forEach((event) => suppressed.add(event));
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
@@ -916,7 +953,7 @@ function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): Annotate
}
}
}
if (recovered.length === 0) {
if (recovered.length === 0 && suppressed.size === 0) {
return dialogue;
}
return [...dialogue.filter((cue) => !suppressed.has(cue)), ...recovered].sort(