fix(subtitles): keep overlapping lines that join an already active cue (#221)

This commit is contained in:
2026-08-27 23:13:29 -07:00
committed by GitHub
parent c2c25c0da6
commit 6e945f0872
14 changed files with 430 additions and 132 deletions
+127 -3
View File
@@ -60,7 +60,7 @@ test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () =
{ startTime: 1, endTime: 3, text: '二行目' },
],
}),
'一行目\n二行目',
'一行目\n\n二行目',
);
});
@@ -195,7 +195,7 @@ test('resolvePrimarySubtitleText combines parsed dialogue with a reconstructed l
],
});
assert.equal(text, '普通のセリフ\n今 手にある');
assert.equal(text, '普通のセリフ\n\n今 手にある');
});
test('resolvePrimarySubtitleText uses fragment grids only to account for live sign pieces', () => {
@@ -300,7 +300,32 @@ test('resolvePrimarySubtitleText combines simultaneous canonical cues in source
],
});
assert.equal(text, 'first\nsecond');
assert.equal(text, 'first\n\nsecond');
});
test('resolveCanonicalPrimarySubtitle orders active cues from top to bottom', () => {
const resolved = resolveCanonicalPrimarySubtitle({
liveText: 'bottom\ntop',
currentTimeSec: 2,
cues: [
{
startTime: 1,
endTime: 3,
text: 'bottom',
source: 'canonical-ass',
assLayout: { kind: 'source-order', sourceOrder: 1, verticalBand: 'bottom' },
},
{
startTime: 1,
endTime: 3,
text: 'top',
source: 'canonical-ass',
assLayout: { kind: 'source-order', sourceOrder: 0, verticalBand: 'top' },
},
],
});
assert.equal(resolved?.text, 'top\n\nbottom');
});
test('resolvePrimarySubtitleText collapses whitespace variants of a canonical lyric', () => {
@@ -498,6 +523,23 @@ test('stripCanonicalFragmentLines drops a live glyph wall with no nearby canonic
);
});
test('resolvePrimarySubtitleText keeps a line joining an active cue despite stale time-pos', () => {
// Issue #220: mpv publishes the combined sub-text the moment a joining line's first
// frame renders, while the observed time-pos still sits just before that line's
// start. The joining cue must not be filtered out as inactive.
assert.equal(
resolvePrimarySubtitleText({
liveText: 'Балда! Балда, балда, балда!\nСестренка не может остановиться',
currentTimeSec: 767.78,
cues: [
{ startTime: 767.19, endTime: 772.78, text: 'Балда! Балда, балда, балда!' },
{ startTime: 767.79, endTime: 771.15, text: 'Сестренка не может остановиться' },
],
}),
'Балда! Балда, балда, балда!\n\nСестренка не может остановиться',
);
});
test('resolvePrimarySubtitleText drops a finished lyric whose exit ghosts outlive it beside a raw line', () => {
// The reconstructed lyric ended at 6.0 but its exit ghost glyphs stay in the live
// text until 7.0, while the next authored line is a plain raw event. The retired cue
@@ -524,3 +566,85 @@ test('resolvePrimarySubtitleText drops a finished lyric whose exit ghosts outliv
'象徴的なパレード',
);
});
test('resolvePrimarySubtitleText stacks simultaneous cues by screen position, not start order', () => {
// A top-anchored lyric and bottom dialogue: mpv draws the lyric above the dialogue for
// the whole overlap. Whichever event started first must not decide the row, or the
// pair swaps every time one side is replaced mid-overlap.
const lyricLayout = { kind: 'source-order', sourceOrder: 0, verticalBand: 'top' } as const;
const dialogueLayout = { kind: 'source-order', sourceOrder: 1, verticalBand: 'bottom' } as const;
const dialogue = {
startTime: 632.2,
endTime: 634.8,
text: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
assLayout: dialogueLayout,
};
// Lyric started before the dialogue...
assert.equal(
resolvePrimarySubtitleText({
liveText: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b\n\u6b4c\u8a5e\uff21',
currentTimeSec: 632.5,
cues: [
{ startTime: 629.5, endTime: 633.5, text: '\u6b4c\u8a5e\uff21', assLayout: lyricLayout },
dialogue,
],
}),
'\u6b4c\u8a5e\uff21\n\n\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
);
// ...and the next lyric starts after it: the rows must not swap.
assert.equal(
resolvePrimarySubtitleText({
liveText: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b\n\u6b4c\u8a5e\uff22',
currentTimeSec: 633.8,
cues: [
dialogue,
{ startTime: 633.5, endTime: 637.0, text: '\u6b4c\u8a5e\uff22', assLayout: lyricLayout },
],
}),
'\u6b4c\u8a5e\uff22\n\n\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
);
});
test('resolvePrimarySubtitleText puts an unreadable placement above bottom dialogue', () => {
// Dialogue is the case that reliably declares a bottom alignment, so a cue whose
// placement could not be read is more often a sign or song line. Keeping dialogue on
// the bottom row means the line worth reading stays where the eye already is.
assert.equal(
resolvePrimarySubtitleText({
liveText: '\u4e0b\u306e\u30bb\u30ea\u30d5\n\u4e0d\u660e\u306a\u884c',
currentTimeSec: 2,
cues: [
{
startTime: 1,
endTime: 3,
text: '\u4e0b\u306e\u30bb\u30ea\u30d5',
assLayout: { kind: 'source-order', sourceOrder: 0, verticalBand: 'bottom' },
},
{
startTime: 1.5,
endTime: 3,
text: '\u4e0d\u660e\u306a\u884c',
assLayout: { kind: 'source-order', sourceOrder: 1 },
},
],
}),
'\u4e0d\u660e\u306a\u884c\n\n\u4e0b\u306e\u30bb\u30ea\u30d5',
);
});
test('resolvePrimarySubtitleText keeps source order when no cue declares a placement', () => {
// SRT and websocket cues carry no layout at all: every cue ties, so the stable sort
// must leave them exactly as the cue list had them.
assert.equal(
resolvePrimarySubtitleText({
liveText: 'First line\nSecond line',
currentTimeSec: 2,
cues: [
{ startTime: 1, endTime: 3, text: 'First line' },
{ startTime: 1.5, endTime: 3, text: 'Second line' },
],
}),
'First line\n\nSecond line',
);
});
+51 -19
View File
@@ -1,4 +1,4 @@
import type { SubtitleCue } from '../../types';
import type { AssVerticalBand, SubtitleCue } from '../../types';
import {
removeAssControlDebrisLines,
removeLiveGlyphFragmentLines,
@@ -57,21 +57,52 @@ function compactWhitespace(text: string): string {
return text.normalize('NFKC').replace(/\s+/gu, '');
}
/**
* Distinct simultaneous cues are separated by a blank line so the display layer can tell
* a wrap inside one utterance from the boundary between two of them. Consumers that read
* the text rather than display it fold these back to single breaks.
*/
const CUE_BOUNDARY = '\n\n';
const VERTICAL_BAND_RANK: Record<AssVerticalBand, number> = { top: 0, middle: 1, bottom: 2 };
/**
* Stack simultaneous cues the way they sit on screen: mpv keeps a top-anchored lyric or
* sign above bottom dialogue for its whole run, while cue-list order follows start time
* and would swap the pair whenever one side is replaced mid-overlap. The band is
* constant per event, so a line never changes rows while it is displayed.
*
* A cue whose placement could not be read -- an unknown style, a script with no styles
* section -- sorts to the top. Dialogue is the case that reliably declares a bottom
* alignment, so what is left unresolved is more often a sign or a song line, and keeping
* the dialogue on the bottom row means the line worth reading stays where the eye
* already is. Sort is stable, so cues sharing a rank keep their existing order.
*/
function orderCuesForDisplay(cues: readonly SubtitleCue[]): SubtitleCue[] {
const rank = (cue: SubtitleCue): number =>
VERTICAL_BAND_RANK[cue.assLayout?.verticalBand ?? 'top'];
return [...cues].sort((a, b) => rank(a) - rank(b));
}
// ASS layers can encode the same visible spacing with ordinary, hard, or
// ideographic spaces. Matching and emission must use the same identity or each
// layer reappears as a copy.
function uniqueCueTexts(cues: readonly SubtitleCue[]): string[] {
const texts: string[] = [];
function uniqueCueTextGroups(cues: readonly SubtitleCue[]): string[] {
const groups: string[] = [];
const seen = new Set<string>();
for (const cue of cues) {
const lines: string[] = [];
for (const line of cue.text.split('\n')) {
const compactText = compactWhitespace(line);
if (!compactText || seen.has(compactText)) continue;
seen.add(compactText);
texts.push(line);
lines.push(line);
}
if (lines.length > 0) {
groups.push(lines.join('\n'));
}
}
return texts;
return groups;
}
function compactLineSegments(text: string): string[] {
@@ -134,23 +165,24 @@ function resolveActiveParsedPrimarySubtitle(options: {
return null;
}
// A cue selected only through the edge tolerance has already ended (or not yet
// started) by its published timing: a finished lyric whose exit ghosts linger into
// the next line. It still explains those live fragments above, but while any cue is
// strictly active, only the active cues supply the displayed text. With no strictly
// active cue, the edge cues remain the display fallback for stale time-pos readings.
const strictlyActive = selected.filter(
(cue) => cue.startTime <= options.currentTimeSec && cue.endTime > options.currentTimeSec,
);
const displayCues = strictlyActive.length > 0 ? strictlyActive : selected;
// A cue selected only through the edge tolerance on its end has already finished by
// its published timing: a lyric whose exit ghosts linger into the next line. It still
// explains those live fragments above, but must not re-surface beside cues that are
// still running. The start side keeps the tolerance: mpv publishes the combined
// sub-text the moment a joining line's first frame renders, while the observed
// time-pos still sits just before that line's start, and the selection above already
// required the cue's text to be on screen (#220). With every selected cue finished,
// the edge cues remain the display fallback for stale time-pos readings.
const unfinished = selected.filter((cue) => cue.endTime > options.currentTimeSec);
const displayCues = unfinished.length > 0 ? unfinished : selected;
// Dense sign grids still explain their raw mpv fragments, but are visual
// typesetting rather than a publishable subtitle line.
const texts = uniqueCueTexts(
displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid'),
const groups = uniqueCueTextGroups(
orderCuesForDisplay(displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid')),
);
return {
text: texts.join('\n'),
text: groups.join(CUE_BOUNDARY),
startTime: Math.min(...displayCues.map((cue) => cue.startTime)),
endTime: Math.max(...displayCues.map((cue) => cue.endTime)),
cues: displayCues,
@@ -217,9 +249,9 @@ export function resolveCanonicalPrimarySubtitle(options: {
return null;
}
const texts = uniqueCueTexts(selected);
const groups = uniqueCueTextGroups(orderCuesForDisplay(selected));
return {
text: texts.join('\n'),
text: groups.join(CUE_BOUNDARY),
startTime: Math.min(...selected.map((cue) => cue.startTime)),
endTime: Math.max(...selected.map((cue) => cue.endTime)),
cues: selected,