fix(subtitles): suppress sweep stragglers and drop-shadow glyph doubles

This commit is contained in:
2026-08-23 21:11:51 -07:00
parent 0ac5db1c92
commit 6d1a1b841a
3 changed files with 127 additions and 18 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 of the line.
- 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.
- 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.
@@ -1570,3 +1570,29 @@ test('parseSubtitleCues suppresses a karaoke highlight sweep without publishing
assert.equal(cues.length, 1);
assert.equal(cues[0]?.text.replace(/\s+/gu, ''), 'tosouomo');
});
test('parseSubtitleCues collapses drop-shadow layer copies offset by a few pixels', () => {
const fragments = [
['me', 580],
['no', 668],
['mae', 770],
['ni', 864],
['no', 939],
['bi', 996],
['ru', 1049],
] as const;
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...fragments.flatMap(([fragment, x], index) => [
`Dialogue: 30,0:01:42.00,0:01:46.92,OP Romaji,,0,0,0,fx,{\\pos(${x},25)\\bord0\\t(${index * 2},${index * 2 + 120},\\blur0.5)}${fragment}`,
// Shadow copy sits 4px off the base glyph and must not read as a second syllable.
`Dialogue: 29,0:01:42.00,0:01:46.92,OP Romaji,,0,0,0,fx,{\\pos(${x + 4},29)\\c&HFFFFFF&\\t(${index * 2},${index * 2 + 120},\\blur9)}${fragment}`,
`Dialogue: 28,0:01:42.00,0:01:46.92,OP Romaji,,0,0,0,fx,{\\pos(${x},25)\\c&HFFFFFF&\\t(${index * 2},${index * 2 + 120},\\blur9)}${fragment}`,
]),
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 1);
assert.equal(cues[0]?.text.replace(/\s+/gu, ''), 'menomaeninobiru');
});
+100 -17
View File
@@ -340,6 +340,27 @@ function fragmentPlacementAnchors(event: AnnotatedSubtitleCue): Set<string> {
return anchors;
}
// Drop-shadow layer copies sit a few pixels off their base glyph, while even tightly
// kerned repeated glyphs in one line ("ii") measure 10px apart or more.
const LAYER_COPY_OFFSET_TOLERANCE_PX = 6;
// One representative point per placement command: the `\pos` point or the `\move`
// midpoint. Comparing raw `\move` endpoints cross-wise misreads a travel distance that
// matches the glyph advance as a layer copy of a neighboring same-letter glyph.
function fragmentAnchorPoints(event: AnnotatedSubtitleCue): AssFragmentPosition[] {
const points: AssFragmentPosition[] = [];
for (const command of event.overrides) {
const name = command.name.toLowerCase();
const args = command.args.split(',').map((value) => Number(value.trim()));
if (name === 'pos' && args.length >= 2 && args.slice(0, 2).every(Number.isFinite)) {
points.push({ x: args[0]!, y: args[1]! });
} else if (name === 'move' && args.length >= 4 && args.slice(0, 4).every(Number.isFinite)) {
points.push({ x: (args[0]! + args[2]!) / 2, y: (args[1]! + args[3]!) / 2 });
}
}
return points;
}
function isRepeatedFragmentCopy(
previous: AnnotatedSubtitleCue,
current: AnnotatedSubtitleCue,
@@ -348,6 +369,17 @@ function isRepeatedFragmentCopy(
if ([...fragmentPlacementAnchors(current)].some((anchor) => previousAnchors.has(anchor))) {
return true;
}
const previousPoints = fragmentAnchorPoints(previous);
const nearbyAnchor = fragmentAnchorPoints(current).some((point) =>
previousPoints.some(
(previousPoint) =>
Math.abs(point.x - previousPoint.x) <= LAYER_COPY_OFFSET_TOLERANCE_PX &&
Math.abs(point.y - previousPoint.y) <= LAYER_COPY_OFFSET_TOLERANCE_PX,
),
);
if (nearbyAnchor) {
return true;
}
return (
previous.startTime === current.startTime &&
previous.endTime === current.endTime &&
@@ -762,15 +794,14 @@ 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 }[] = [];
interface FragmentInterval {
startTime: number;
endTime: number;
}
/** Event time ranges with layer copies (same text, placement, and timing) collapsed. */
function distinctFragmentIntervals(events: readonly AnnotatedSubtitleCue[]): FragmentInterval[] {
const intervals: FragmentInterval[] = [];
const seen = new Set<string>();
for (const event of events) {
const anchors = [...fragmentPlacementAnchors(event)].sort().join('|');
@@ -779,20 +810,68 @@ function isProgressiveHighlightSweep(events: readonly AnnotatedSubtitleCue[]): b
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 intervals.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime);
}
function intervalsNeverCoexist(intervals: readonly FragmentInterval[]): boolean {
let latestEnd = -Infinity;
for (const interval of intervals) {
if (interval.startTime < latestEnd - 0.001) {
return false;
}
latestEnd = Math.max(latestEnd, intervals[index]!.endTime);
latestEnd = Math.max(latestEnd, interval.endTime);
}
return true;
}
/**
* 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 = distinctFragmentIntervals(events);
return intervals.length >= 2 && intervalsNeverCoexist(intervals);
}
/**
* Timing clusters split a long sweep unevenly, leaving stragglers the per-cluster check
* cannot judge: a two-event tail reconstructs on relaxed evidence, and a lone held
* syllable stays raw and publishes as its own flickering cue. When an entire style group
* reads as one chained repaint -- many short positioned animated fragments, no two ever
* on screen together, transitions mostly back-to-back -- the whole group is highlight
* decoration and none of it is publishable text. Independent one-off signs sharing a
* style stay published: they are few, longer, or separated by real gaps.
*/
function isProgressiveHighlightSweepGroup(events: readonly AnnotatedSubtitleCue[]): boolean {
if (
events.length < MIN_FRAGMENT_LINE_EVENTS ||
!events.every((event) => fragmentPlacementAnchors(event).size > 0) ||
!hasAssAnimationEvidence(events)
) {
return false;
}
const lengths = events
.map((event) => compactCueMatchText(event).length)
.sort((left, right) => left - right);
if ((lengths[Math.floor(lengths.length / 2)] ?? Infinity) > MAX_FRAGMENT_MEDIAN_LENGTH) {
return false;
}
const intervals = distinctFragmentIntervals(events);
if (intervals.length < 2 || !intervalsNeverCoexist(intervals)) {
return false;
}
let abutting = 0;
for (let index = 1; index < intervals.length; index += 1) {
if (Math.abs(intervals[index]!.startTime - intervals[index - 1]!.endTime) <= 0.1) {
abutting += 1;
}
}
return abutting * 2 >= intervals.length - 1;
}
function decodeSingleAssFragment(cue: AnnotatedSubtitleCue): string | null {
const visibleLines = decodeSubtitleCueText(cue.rawText)
.split('\n')
@@ -929,6 +1008,10 @@ function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): Annotate
const lineEvents = decorative.size
? events.filter((event) => !decorative.has(event))
: events;
if (isProgressiveHighlightSweepGroup(lineEvents)) {
lineEvents.forEach((event) => suppressed.add(event));
continue;
}
for (const cluster of clusterAssFragmentEvents(lineEvents)) {
const line = reconstructAssFragmentLine(cluster.events);
if (!line) {