mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-25 00:15:27 -07:00
fix(subtitles): suppress karaoke highlight sweeps from reconstructed lyrics
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: subtitles
|
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.
|
- 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 of the line.
|
||||||
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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');
|
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;
|
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 {
|
function decodeSingleAssFragment(cue: AnnotatedSubtitleCue): string | null {
|
||||||
const visibleLines = decodeSubtitleCueText(cue.rawText)
|
const visibleLines = decodeSubtitleCueText(cue.rawText)
|
||||||
.split('\n')
|
.split('\n')
|
||||||
@@ -903,6 +934,12 @@ function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): Annotate
|
|||||||
if (!line) {
|
if (!line) {
|
||||||
continue;
|
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);
|
recovered.push(line);
|
||||||
cluster.events.forEach((event) => suppressed.add(event));
|
cluster.events.forEach((event) => suppressed.add(event));
|
||||||
// Decoration is timed to the line it overlays, so it disappears with the line's
|
// 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;
|
||||||
}
|
}
|
||||||
return [...dialogue.filter((cue) => !suppressed.has(cue)), ...recovered].sort(
|
return [...dialogue.filter((cue) => !suppressed.has(cue)), ...recovered].sort(
|
||||||
|
|||||||
Reference in New Issue
Block a user