mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-25 12:15:26 -07:00
fix(subtitles): suppress karaoke highlight sweeps from reconstructed lyrics
This commit is contained in:
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user