fix(subtitles): collapse duplicate primary ASS style layers

- Use parsed cues when they fully explain mpv live text
- Preserve unmatched overlapping dialogue and signs
This commit is contained in:
2026-08-18 01:51:19 -07:00
parent db61ce358d
commit 1d1b0c7bb6
4 changed files with 145 additions and 6 deletions
@@ -0,0 +1,4 @@
type: fixed
area: subtitles
- Primary ASS subtitles now use the active parsed cue when it fully accounts for mpv's live text, preventing fill, border, blur, and shadow copies of the same full-span lyric from appearing repeatedly while preserving unmatched overlapping dialogue and signs.
@@ -3,7 +3,7 @@
# Subtitle Overlay Priming
Status: active
Last verified: 2026-08-17
Last verified: 2026-08-18
Owner: Kyle Yasuda
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
@@ -69,6 +69,11 @@ coming and prefetching would otherwise idle for the rest of the cue.
## Live Cue Delivery
- Primary live text first resolves recovered canonical ASS animations. Otherwise, when
every live mpv line matches an active parsed cue, it uses the parsed cue text so exact
full-span style layers appear once instead of repeating for fill, border, blur, and
shadow events. Any unmatched live line keeps the complete live stack, preserving
dialogue or signs that overlap a lyric.
- A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so
live work does not contend for Yomitan state.
- If a newer cue arrives while an older line is still tokenizing, the newer plain cue or empty
@@ -1,11 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import {
resolveCanonicalPrimarySubtitle,
resolvePrimarySubtitleText,
stripCanonicalFragmentLines,
} from './primary-subtitle-text';
test('resolvePrimarySubtitleText collapses full-span ASS style layers through parsed cues', () => {
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 3,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)\\bord0\\blur0.8}鏡の奥まで目を凝らして',
'Dialogue: 2,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)}鏡の奥まで目を凝らして',
'Dialogue: 1,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)\\bord6}鏡の奥まで目を凝らして',
'Dialogue: 0,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)\\bord8\\blur4}鏡の奥まで目を凝らして',
].join('\n');
const cues = parseSubtitleCues(ass, 'polar-opposites-s01e08.ass');
assert.deepEqual(cues, [
{ startTime: 22 * 60 + 40.05, endTime: 22 * 60 + 45.76, text: '鏡の奥まで目を凝らして' },
]);
assert.equal(
resolvePrimarySubtitleText({
liveText: [
'鏡の奥まで目を凝らして',
'鏡の奥まで目を凝らして',
'鏡の奥まで目を凝らして',
'鏡の奥まで目を凝らして',
].join('\n'),
currentTimeSec: 22 * 60 + 44,
cues,
}),
'鏡の奥まで目を凝らして',
);
});
test('resolvePrimarySubtitleText keeps live text when active parsed cues do not explain it all', () => {
const liveText = '普通のセリフ\n鏡の奥まで目を凝らして\n鏡の奥まで目を凝らして';
assert.equal(
resolvePrimarySubtitleText({
liveText,
currentTimeSec: 2,
cues: [{ startTime: 1, endTime: 3, text: '鏡の奥まで目を凝らして' }],
}),
liveText,
);
});
test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () => {
assert.equal(
resolvePrimarySubtitleText({
liveText: '一行目\n一行目\n二行目\n二行目',
currentTimeSec: 2,
cues: [
{ startTime: 1, endTime: 3, text: '一行目' },
{ startTime: 1, endTime: 3, text: '二行目' },
],
}),
'一行目\n二行目',
);
});
test('resolvePrimarySubtitleText tolerates stale time-pos at a parsed cue edge', () => {
assert.equal(
resolvePrimarySubtitleText({
liveText: '新しい行\n新しい行',
currentTimeSec: 0.8,
cues: [{ startTime: 1, endTime: 3, text: '新しい行' }],
}),
'新しい行',
);
});
test('resolvePrimarySubtitleText prefers an active canonical cue over flattened mpv glyphs', () => {
// mpv renders each simultaneously active ASS event on its own sub-text line.
const text = resolvePrimarySubtitleText({
+66 -5
View File
@@ -3,13 +3,13 @@ import type { SubtitleCue } from '../../types';
// Slack on top of each cue's recorded animation envelope, for time-pos observation
// staleness and small user sub-delay offsets. The envelope itself covers how far
// entrance/exit frames actually run past the authored timing.
const CANONICAL_ANIMATION_EDGE_TOLERANCE_SECONDS = 1;
const LIVE_CUE_EDGE_TOLERANCE_SECONDS = 1;
export interface ResolvedPrimarySubtitle {
text: string;
startTime: number;
endTime: number;
/** The canonical cues behind `text`, for consumers that record lines individually. */
/** The parsed cues behind `text`, for consumers that record lines individually. */
cues: SubtitleCue[];
}
@@ -30,8 +30,8 @@ function nearbyCanonicalCues(
}
const span = animationSpan(cue);
return (
span.end >= currentTimeSec - CANONICAL_ANIMATION_EDGE_TOLERANCE_SECONDS &&
span.start <= currentTimeSec + CANONICAL_ANIMATION_EDGE_TOLERANCE_SECONDS
span.end >= currentTimeSec - LIVE_CUE_EDGE_TOLERANCE_SECONDS &&
span.start <= currentTimeSec + LIVE_CUE_EDGE_TOLERANCE_SECONDS
);
});
}
@@ -40,6 +40,65 @@ function compactWhitespace(text: string): string {
return text.replace(/\s+/gu, '');
}
function compactLineSegments(text: string): string[] {
return text.split('\n').map(compactWhitespace).filter(Boolean);
}
/**
* Parsed cues have already collapsed exact ASS layers and animation runs. Trust that
* cleaner view only when every live mpv line is accounted for by an active parsed cue.
* This keeps unrelated concurrent dialogue on the live fallback while removing style
* stacks where mpv repeats one full lyric for fill, border, blur, and shadow layers.
*/
function resolveActiveParsedPrimarySubtitle(options: {
liveText: string;
currentTimeSec: number;
cues: readonly SubtitleCue[] | null | undefined;
}): ResolvedPrimarySubtitle | null {
if (!Number.isFinite(options.currentTimeSec)) {
return null;
}
const liveSegments = compactLineSegments(options.liveText);
if (liveSegments.length === 0) {
return null;
}
const liveSegmentSet = new Set(liveSegments);
const selected = (options.cues ?? []).filter((cue) => {
if (
cue.startTime > options.currentTimeSec + LIVE_CUE_EDGE_TOLERANCE_SECONDS ||
cue.endTime <= options.currentTimeSec - LIVE_CUE_EDGE_TOLERANCE_SECONDS
) {
return false;
}
const cueSegments = compactLineSegments(cue.text);
return cueSegments.length > 0 && cueSegments.every((segment) => liveSegmentSet.has(segment));
});
if (selected.length === 0) {
return null;
}
const parsedSegmentSet = new Set(selected.flatMap((cue) => compactLineSegments(cue.text)));
if (!liveSegments.every((segment) => parsedSegmentSet.has(segment))) {
return null;
}
const texts: string[] = [];
const seen = new Set<string>();
for (const cue of selected) {
if (!seen.has(cue.text)) {
seen.add(cue.text);
texts.push(cue.text);
}
}
return {
text: texts.join('\n'),
startTime: Math.min(...selected.map((cue) => cue.startTime)),
endTime: Math.max(...selected.map((cue) => cue.endTime)),
cues: selected,
};
}
/**
* mpv's `sub-text` renders each simultaneously active ASS event on its own line, so
* while a generated animation plays every live line is a contiguous piece of the
@@ -155,6 +214,8 @@ export function resolvePrimarySubtitleText(options: {
liveText: options.liveText,
currentTimeSec: options.currentTimeSec,
cues: options.cues,
})?.text ?? options.liveText
})?.text ??
resolveActiveParsedPrimarySubtitle(options)?.text ??
options.liveText
);
}