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
+15
View File
@@ -218,3 +218,18 @@ test('removeLiveGlyphFragmentLines leaves ordinary short lines alone', () => {
const text = 'え\nはい。\nそうだな';
assert.equal(removeLiveGlyphFragmentLines(text), text);
});
test('normalizePlainSubtitleText folds cue-boundary blank lines for text consumers', () => {
// The display layer splits on the blank line before normalizing; everyone else --
// tokenizer, cache key, dedup gate, mined sentence -- wants the plain line form.
assert.equal(
normalizePlainSubtitleText('\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee'),
'\u4e00\u884c\u76ee\n\u4e8c\u884c\u76ee',
);
assert.equal(
normalizePlainSubtitleText('\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee', {
collapseLineBreaks: true,
}),
'\u4e00\u884c\u76ee \u4e8c\u884c\u76ee',
);
});
+4
View File
@@ -153,6 +153,10 @@ export function normalizePlainSubtitleText(
);
if (collapseLineBreaks) {
normalized = normalized.replace(/\n/g, ' ').replace(/\s+/g, ' ');
} else {
// Simultaneous cues reach the display layer separated by a blank line; every other
// consumer wants the plain one-break-per-line form.
normalized = normalized.replace(/\n{2,}/g, '\n');
}
return trim ? normalized.trim() : normalized;
+24 -4
View File
@@ -1927,9 +1927,7 @@ test('parseSubtitleCues does not double a line rendered whole beside its glyph s
['わ', 1022],
['ね', 1064],
] as const;
const wholeLine = glyphs
.map(([glyph]) => `{\\an5\\fad(300,500)\\pos(960,50)}${glyph}`)
.join('');
const wholeLine = glyphs.map(([glyph]) => `{\\an5\\fad(300,500)\\pos(960,50)}${glyph}`).join('');
const content = [
...eventsHeader,
`Dialogue: 1,0:00:17.29,0:00:18.99,OP - JP,,0,0,0,,${wholeLine}`,
@@ -1952,7 +1950,7 @@ test('parseSubtitleCues drops a wall of near-invisible positioned texture string
// faint translation is one or two events and stays published.
const content = [
...eventsHeader,
'Dialogue: 90,0:00:12.66,0:00:14.91,Default,,0,0,0,,We\'ll play as a band, and then...',
"Dialogue: 90,0:00:12.66,0:00:14.91,Default,,0,0,0,,We'll play as a band, and then...",
...Array.from(
{ length: 12 },
(_, index) =>
@@ -1997,3 +1995,25 @@ test('parseSubtitleCues keeps hidden events hidden when a transform animates an
['grows into view', 'wipes into view'],
);
});
test('parseAssCues records the vertical band from style alignment, overrides, and \\pos', () => {
const ass = [
'[Script Info]',
'PlayResY: 720',
'',
'[V4+ Styles]',
'Format: Name, Fontname, Fontsize, PrimaryColour, Bold, Alignment, MarginV, Encoding',
'Style: Bottom,Arial,54,&H00FFFFFF,0,2,30,1',
'Style: TopSong,Arial,54,&H00FFFFFF,0,9,12,1',
'',
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:03.00,Bottom,,0,0,0,,\u4e0b\u306e\u30bb\u30ea\u30d5',
'Dialogue: 0,0:00:01.00,0:00:03.00,TopSong,,0,0,0,,\u6b4c\u8a5e\u306e\u884c',
'Dialogue: 0,0:00:01.00,0:00:03.00,Bottom,,0,0,0,,{\\an8}\u4e0a\u66f8\u304d\u306e\u884c',
'Dialogue: 0,0:00:01.00,0:00:03.00,Bottom,,0,0,0,,{\\pos(640,20)}\u770b\u677f\u306e\u884c',
].join('\n');
const bands = parseAssCues(ass).map((cue) => cue.assLayout?.verticalBand);
assert.deepEqual(bands, ['bottom', 'top', 'top', 'top']);
});
+126 -16
View File
@@ -10,10 +10,13 @@ import {
} from './ass-text';
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
/** Vertical third of the screen a cue is authored to occupy. */
export type AssVerticalBand = 'top' | 'middle' | 'bottom';
export type AssCueLayout =
| { kind: 'positioned'; sourceOrder: number; y: number }
| { kind: 'fragment-grid'; sourceOrder: number }
| { kind: 'source-order'; sourceOrder: number };
| { kind: 'positioned'; sourceOrder: number; y: number; verticalBand?: AssVerticalBand }
| { kind: 'fragment-grid'; sourceOrder: number; verticalBand?: AssVerticalBand }
| { kind: 'source-order'; sourceOrder: number; verticalBand?: AssVerticalBand };
export interface SubtitleCue {
startTime: number;
@@ -481,9 +484,7 @@ function structuralOverrideSignature(cue: AnnotatedSubtitleCue): string {
let signature = structuralSignatureCache.get(cue);
if (signature === undefined) {
const names = new Set(
cue.overrides.map(
(command) => `${command.animated ? '~' : ''}${command.name.toLowerCase()}`,
),
cue.overrides.map((command) => `${command.animated ? '~' : ''}${command.name.toLowerCase()}`),
);
signature = [...names].sort().join(',');
structuralSignatureCache.set(cue, signature);
@@ -544,9 +545,7 @@ function buildCoalescedCopy(members: readonly AnnotatedSubtitleCue[]): Annotated
* while the anchor says one glyph. Merging each stack into a single presence spanning
* the union window lets timing clusters see the authored line instead of its phases.
*/
function coalesceAssAnchorCopies(
events: readonly AnnotatedSubtitleCue[],
): AnnotatedSubtitleCue[] {
function coalesceAssAnchorCopies(events: readonly AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
const buckets = new Map<string, number[]>();
const anchorPoints: (AssFragmentPosition[] | null)[] = events.map(() => null);
events.forEach((event, index) => {
@@ -1362,8 +1361,7 @@ function isRepeatedGlyphText(cue: AnnotatedSubtitleCue): boolean {
function isClippedRepeatedGlyphFragment(cue: AnnotatedSubtitleCue): boolean {
return (
isRepeatedGlyphText(cue) &&
(hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip'))
isRepeatedGlyphText(cue) && (hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip'))
);
}
@@ -2055,6 +2053,111 @@ function recoverCanonicalAssEvents({
);
}
function bandFromNumpadAlignment(alignment: number): AssVerticalBand | null {
if (alignment >= 7 && alignment <= 9) return 'top';
if (alignment >= 4 && alignment <= 6) return 'middle';
if (alignment >= 1 && alignment <= 3) return 'bottom';
return null;
}
// SSA v4 alignment reuses the legacy `\a` codes: 1-3 bottom, +4 top, +8 middle.
function bandFromLegacyAlignment(alignment: number): AssVerticalBand | null {
if (alignment >= 9 && alignment <= 11) return 'middle';
if (alignment >= 5 && alignment <= 7) return 'top';
if (alignment >= 1 && alignment <= 3) return 'bottom';
return null;
}
interface AssPlacementContext {
playResY: number | null;
/** Lowercased style name -> vertical band from the style's Alignment column. */
styleBands: Map<string, AssVerticalBand>;
}
const EMPTY_PLACEMENT_CONTEXT: AssPlacementContext = { playResY: null, styleBands: new Map() };
function parseAssPlacementContext(content: string): AssPlacementContext {
const styleBands = new Map<string, AssVerticalBand>();
let playResY: number | null = null;
let section: 'info' | 'v4plus' | 'v4' | null = null;
let alignmentIndex = -1;
let nameIndex = -1;
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
const sectionName = trimmed.toLowerCase();
section =
sectionName === '[script info]'
? 'info'
: sectionName === '[v4+ styles]'
? 'v4plus'
: sectionName === '[v4 styles]'
? 'v4'
: null;
alignmentIndex = -1;
nameIndex = -1;
continue;
}
if (section === 'info') {
const resMatch = trimmed.match(/^playresy\s*:\s*(\d+(?:\.\d+)?)\s*$/i);
if (resMatch) playResY = Number(resMatch[1]);
continue;
}
if (section !== 'v4plus' && section !== 'v4') continue;
const separator = trimmed.indexOf(':');
if (separator < 0) continue;
const key = trimmed.slice(0, separator).trim().toLowerCase();
const fields = trimmed.slice(separator + 1).split(',');
if (key === 'format') {
const names = fields.map((field) => field.trim().toLowerCase());
alignmentIndex = names.indexOf('alignment');
nameIndex = names.indexOf('name');
continue;
}
if (key !== 'style' || alignmentIndex < 0 || nameIndex < 0) continue;
const styleName = fields[nameIndex]?.trim().toLowerCase();
const alignment = Number(fields[alignmentIndex]?.trim());
if (!styleName || !Number.isFinite(alignment)) continue;
const band =
section === 'v4plus'
? bandFromNumpadAlignment(alignment)
: bandFromLegacyAlignment(alignment);
if (band) styleBands.set(styleName, band);
}
return { playResY, styleBands };
}
/**
* Where on screen mpv will draw this event: an explicit `\pos`/`\move` coordinate when
* the script declares its coordinate space, else an `\an`/`\a` override, else the
* style's Alignment. Constant for the life of the event, which is what lets simultaneous
* lines keep a stable stacking order in the overlay.
*/
function resolveVerticalBand(
overrides: readonly AssOverrideCommand[],
y: number | null,
style: string,
context: AssPlacementContext,
): AssVerticalBand | undefined {
if (y !== null && context.playResY && context.playResY > 0) {
const ratio = y / context.playResY;
return ratio < 1 / 3 ? 'top' : ratio < 2 / 3 ? 'middle' : 'bottom';
}
for (const command of overrides) {
if (command.animated) continue;
const name = command.name.toLowerCase();
if (name !== 'an' && name !== 'a') continue;
const band =
name === 'an'
? bandFromNumpadAlignment(Number(command.args))
: bandFromLegacyAlignment(Number(command.args));
if (band) return band;
}
return context.styleBands.get(style.trim().toLowerCase());
}
function parseAssCoordinate(value: string | undefined): number | null {
if (!value?.trim()) return null;
const coordinate = Number(value.trim());
@@ -2064,6 +2167,8 @@ function parseAssCoordinate(value: string | undefined): number | null {
function buildAssCueLayout(
overrides: readonly AssOverrideCommand[],
sourceOrder: number,
style: string,
placement: AssPlacementContext,
): AssCueLayout {
let y: number | null = null;
for (const command of overrides) {
@@ -2081,14 +2186,18 @@ function buildAssCueLayout(
y = (startY + endY) / 2;
}
}
return y === null
? { kind: 'source-order', sourceOrder }
: { kind: 'positioned', sourceOrder, y };
const verticalBand = resolveVerticalBand(overrides, y, style, placement);
const base: AssCueLayout =
y === null ? { kind: 'source-order', sourceOrder } : { kind: 'positioned', sourceOrder, y };
return verticalBand ? { ...base, verticalBand } : base;
}
function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
const cues: AnnotatedSubtitleCue[] = [];
const comments: AnnotatedSubtitleCue[] = [];
const placement = content.includes('[')
? parseAssPlacementContext(content)
: EMPTY_PLACEMENT_CONTEXT;
const lines = content.split(/\r?\n/);
let inEventsSection = false;
let eventOrder = 0;
@@ -2185,12 +2294,13 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
const effect = readField(fields, fieldIndex.effect);
const layer = Number(readField(fields, fieldIndex.layer));
const overrides = collectAssOverrideCommands(rawText);
const style = readField(fields, fieldIndex.style);
const cue: AnnotatedSubtitleCue = {
startTime,
endTime,
text,
rawText,
style: readField(fields, fieldIndex.style),
style,
layer: Number.isFinite(layer) ? layer : 0,
name: readField(fields, fieldIndex.name),
effect,
@@ -2198,7 +2308,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
overrides,
overrideSignature: assOverrideSignature(overrides),
order: eventOrder,
assLayout: buildAssCueLayout(overrides, eventOrder),
assLayout: buildAssCueLayout(overrides, eventOrder, style, placement),
};
eventOrder += 1;
if (eventPrefix === ASS_COMMENT_PREFIX) {
@@ -134,7 +134,7 @@ export function createSubtitleProcessingController(
try {
const cachedTokenized = getCachedTokenization(text);
if (cachedTokenized) {
output = cachedTokenized;
output = { ...cachedTokenized, text };
} else {
// Cache miss: show the plain line on time; the tokenized payload
// upgrades it once ready. Skipped on refreshes of an already
@@ -266,7 +266,7 @@ export function createSubtitleProcessingController(
lastEmittedText = text;
lastEmittedGeneration = cacheGeneration;
lastPlainEmittedText = null;
return cached;
return { ...cached, text };
},
hasCachedSubtitle: (text: string) => {
const cacheKey = normalizeSubtitleCacheKey(text);
+17
View File
@@ -84,6 +84,17 @@ function createDeferred<T>() {
};
}
test('tokenizeSubtitle keeps the blank line separating simultaneous cues', async () => {
// The tokenized payload's text drives display; folding the cue boundary would merge
// two speakers back onto one line the moment tokenization upgrades the plain emit.
const result = await tokenizeSubtitle(
'\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee',
makeDeps({ getYomitanExt: () => null }),
);
assert.equal(result.text, '\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee');
});
test('tokenizeSubtitle splits same-line grammar endings before applying annotations', async () => {
const result = await tokenizeSubtitle(
'猫です',
@@ -1682,6 +1693,12 @@ test('tokenizeSubtitle normalizes newlines before Yomitan parse request', async
assert.equal(result.tokens, null);
});
test('tokenizeSubtitle preserves CRLF boundaries between simultaneous cues', async () => {
const result = await tokenizeSubtitle('a\r\n\r\nb', makeDeps());
assert.deepEqual(result, { text: 'a\n\nb', tokens: null });
});
test('tokenizeSubtitle collapses zero-width separators before Yomitan parse request', async () => {
let parseInput = '';
const result = await tokenizeSubtitle(
+9 -1
View File
@@ -887,7 +887,15 @@ export async function tokenizeSubtitle(
text: string,
deps: TokenizerServiceDeps,
): Promise<SubtitleData> {
const displayText = normalizePlainSubtitleText(text);
// Normalize per cue group: the blank line separating simultaneous cues is display
// structure the payload text must keep, or the tokenized upgrade re-merges lines the
// provisional plain emit already showed apart.
const displayText = text
.replace(/\r\n/g, '\n')
.split(/\n{2,}/)
.map((part) => normalizePlainSubtitleText(part))
.filter(Boolean)
.join('\n\n');
// ASS decoding already happened upstream (cue parser for files, mpv for live text), so
// all this drops is whitespace -- but a whitespace-only line still normalizes to empty.
+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,
+29
View File
@@ -11,6 +11,7 @@ import {
getFrequencyRankLabelForToken,
getJlptLevelLabelForToken,
normalizeSubtitle,
normalizeSubtitleForDisplay,
prepareSecondarySubtitleLines,
sanitizeSubtitleHoverTokenColor,
shouldRenderTokenizedSubtitle,
@@ -1004,6 +1005,34 @@ test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks i
);
});
test('normalizeSubtitleForDisplay always breaks between simultaneous cues', () => {
// The blank line marks two distinct cues on screen at once. Flattening it would run a
// sign or a second speaker into the line beside it as one sentence.
const twoCues =
'\u6b21\u306f\u9b3c\u5b50\u6bcd\u795e\u524d\u3000\u9b3c\u5b50\u6bcd\u795e\u524d\n\n\u611b\u97f3\u3061\u3083\u3093\u3000\u3082\u3046\u5199\u771f\u4e0a\u3052\u3066\u308b';
assert.equal(
normalizeSubtitleForDisplay(twoCues, false),
'\u6b21\u306f\u9b3c\u5b50\u6bcd\u795e\u524d \u9b3c\u5b50\u6bcd\u795e\u524d\n\u611b\u97f3\u3061\u3083\u3093 \u3082\u3046\u5199\u771f\u4e0a\u3052\u3066\u308b',
);
assert.equal(normalizeSubtitleForDisplay(twoCues, true), twoCues.replace('\n\n', '\n'));
});
test('normalizeSubtitleForDisplay preserves CRLF boundaries between simultaneous cues', () => {
assert.equal(normalizeSubtitleForDisplay('a\r\n\r\nb', false), 'a\nb');
});
test('normalizeSubtitleForDisplay still flattens a wrap inside one cue', () => {
// A typesetter's \\N inside a single utterance is what preserveLineBreaks governs.
assert.equal(
normalizeSubtitleForDisplay(
'\u5e38\u4eba\u304c\u4f7f\u3048\u3070\\N\u305d\u306e\u5727\u5012\u7684\u306a\u529b\u306b',
false,
),
'\u5e38\u4eba\u304c\u4f7f\u3048\u3070 \u305d\u306e\u5727\u5012\u7684\u306a\u529b\u306b',
);
});
test('normalizeSubtitle leaves already-decoded text alone', () => {
// Primary subtitle text is decoded from ASS once, upstream: by mpv for live lines and
// by the cue parser for prefetched ones. A brace that survives that is literal text.
+19 -7
View File
@@ -50,6 +50,21 @@ export function normalizeSubtitle(text: string, trim = true, collapseLineBreaks
return normalizePlainSubtitleText(text, { trim, collapseLineBreaks });
}
/**
* Display form of a resolved subtitle. `preserveLineBreaks` governs wrapping inside one
* utterance, which is what a typesetter's `\N` means. The blank line the resolver puts
* between two simultaneous cues is a different thing and always breaks, so a sign or a
* second speaker never runs into the line beside it.
*/
export function normalizeSubtitleForDisplay(text: string, preserveLineBreaks: boolean): string {
return text
.replace(/\r\n/g, '\n')
.split(/\n{2,}/)
.map((cueText) => normalizeSubtitle(cueText, true, !preserveLineBreaks))
.filter((cueText) => cueText.length > 0)
.join('\n');
}
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
const SAFE_CSS_COLOR_PATTERN =
/^(?:#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgba?|hsla?)\([^)]*\)|var\([^)]*\)|[a-zA-Z]+)$/;
@@ -414,16 +429,13 @@ function renderWithTokens(
const fragment = document.createDocumentFragment();
if (sourceText) {
const normalizedSource = normalizeSubtitle(sourceText, true, !preserveLineBreaks);
const normalizedSource = normalizeSubtitleForDisplay(sourceText, preserveLineBreaks);
const segments = alignTokensToSourceText(tokens, normalizedSource);
for (const segment of segments) {
if (segment.kind === 'text') {
if (preserveLineBreaks) {
renderPlainTextPreserveLineBreaks(fragment, segment.text);
} else {
fragment.appendChild(document.createTextNode(segment.text));
}
// Normalization already resolved which breaks survive; every one left is real.
renderPlainTextPreserveLineBreaks(fragment, segment.text);
continue;
}
@@ -748,7 +760,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
return;
}
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
const normalized = normalizeSubtitleForDisplay(text, ctx.state.preserveSubtitleLineBreaks);
const hasRenderableTokens =
shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && Boolean(tokens);
if (
+2 -2
View File
@@ -1,4 +1,4 @@
import type { SubtitleCue } from '../core/services/subtitle-cue-parser';
import type { AssVerticalBand, SubtitleCue } from '../core/services/subtitle-cue-parser';
export enum PartOfSpeech {
noun = 'noun',
@@ -187,7 +187,7 @@ export interface ResolvedTokenPos2ExclusionConfig {
export type FrequencyDictionaryMode = 'single' | 'banded';
export type { SubtitleCue };
export type { AssVerticalBand, SubtitleCue };
export type SubtitleSidebarLayout = 'overlay' | 'embedded';