fix(subtitles): recover positioned ASS word spacing and drop control debris (#217)

This commit is contained in:
2026-08-25 02:22:21 -07:00
committed by GitHub
parent ed7d3f4c3d
commit e816b3b371
26 changed files with 2934 additions and 105 deletions
@@ -46,7 +46,10 @@ export type InternalSubtitleTrackExtractor = (
track: MpvSubtitleTrackLike,
) => Promise<ExtractedInternalSubtitleTrack | null>;
const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000;
// Subtitle packets are interleaved through the container, so extraction reads the
// entire file. Network mounts move ~100 MB/s on gigabit, so large Bluray remuxes
// need well over 30 seconds.
const DEFAULT_EXTRACTION_TIMEOUT_MS = 120_000;
export function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
@@ -426,6 +426,13 @@ test('canonical ASS cues replace live glyph spam for display, history, and immer
text: '飛び越えてみたくて',
source: 'canonical-ass',
},
{
startTime: 10,
endTime: 12,
text: 'MaidCafeMaidCafe',
source: 'reconstructed-ass',
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
},
],
currentMediaPath: '/video.mkv',
currentSubText: '',
@@ -503,6 +510,11 @@ test('canonical ASS cues replace live glyph spam for display, history, and immer
handlers.recordSubtitleTiming('今', 0.8, 1.5);
assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
handlers.recordImmersionSubtitleLine('Maid\nCafe', 10, 12);
handlers.recordSubtitleTiming('Maid\nCafe', 10, 12);
assert.equal(immersion.length, 3);
assert.equal(timing.length, 5);
});
test('subtitle-track changes stop stale canonical cues from substituting immediately', () => {
+8 -1
View File
@@ -218,6 +218,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
return;
}
text = stripFragmentsForRecording(text, start);
if (!text.trim()) {
return;
}
if (!immersionLineDedupGate.shouldRecord({ text, startSec: start, endSec: end })) {
return;
}
@@ -228,8 +231,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
const canonical = resolveCanonicalSample(text, start);
if (!canonical) {
const recordableText = stripFragmentsForRecording(text, start);
if (!recordableText.trim()) {
return;
}
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
stripFragmentsForRecording(text, start),
recordableText,
start,
end,
secondaryText,
@@ -64,6 +64,34 @@ test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () =
);
});
test('resolvePrimarySubtitleText removes duplicate lines across multiline parsed cues', () => {
assert.equal(
resolvePrimarySubtitleText({
liveText: 'First line\nSecond line\nFirst line',
currentTimeSec: 2,
cues: [
{ startTime: 1, endTime: 3, text: 'First line\nSecond line' },
{ startTime: 1, endTime: 3, text: 'First line' },
],
}),
'First line\nSecond line',
);
});
test('resolvePrimarySubtitleText removes equivalent full-width duplicate lines', () => {
assert.equal(
resolvePrimarySubtitleText({
liveText: '20分53秒\n20分53秒',
currentTimeSec: 2,
cues: [
{ startTime: 1, endTime: 3, text: '20分53秒' },
{ startTime: 1, endTime: 3, text: '20分53秒' },
],
}),
'20分53秒',
);
});
test('resolvePrimarySubtitleText collapses whitespace variants of one ASS lyric', () => {
const ass = [
'[Events]',
@@ -152,6 +180,73 @@ test('resolvePrimarySubtitleText keeps concurrent dialogue that is not part of t
assert.equal(text, '普通のセリフ\n今\n手にある');
});
test('resolvePrimarySubtitleText combines parsed dialogue with a reconstructed lyric', () => {
const text = resolvePrimarySubtitleText({
liveText: '普通のセリフ\n今\n今\n手\n手\nにある\nにある',
currentTimeSec: 2,
cues: [
{ startTime: 1, endTime: 3, text: '普通のセリフ' },
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'reconstructed-ass',
},
],
});
assert.equal(text, '普通のセリフ\n今 手にある');
});
test('resolvePrimarySubtitleText uses fragment grids only to account for live sign pieces', () => {
const text = resolvePrimarySubtitleText({
liveText: 'Ordinary dialogue\nMaid\nCafe',
currentTimeSec: 2,
cues: [
{ startTime: 1, endTime: 3, text: 'Ordinary dialogue' },
{
startTime: 1,
endTime: 3,
text: 'MaidCafeMaidCafe',
source: 'reconstructed-ass',
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
},
],
});
assert.equal(text, 'Ordinary dialogue');
});
test('resolvePrimarySubtitleText drops malformed ASS control debris from live text', () => {
const cues = parseSubtitleCues(
[
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Visible line',
].join('\n'),
'test.ass',
);
assert.equal(
resolvePrimarySubtitleText({
liveText: 'Visible line\n\\\n{\\fr0',
currentTimeSec: 2,
cues,
}),
'Visible line',
);
});
test('resolvePrimarySubtitleText preserves SRT text that resembles ASS control debris', () => {
const liveText = 'Visible line\n\\\n{\\fr0';
const cues = parseSubtitleCues(
['1', '00:00:01,000 --> 00:00:03,000', liveText].join('\n'),
'test.srt',
);
assert.equal(resolvePrimarySubtitleText({ liveText, currentTimeSec: 2, cues }), liveText);
});
test('resolvePrimarySubtitleText keeps a fresh line starting just after the animation ended', () => {
const text = resolvePrimarySubtitleText({
liveText: '次のセリフ',
@@ -382,3 +477,50 @@ test('resolveCanonicalPrimarySubtitle picks the cue its fragments spell, not the
'今 手にある',
);
});
test('resolvePrimarySubtitleText suppresses a live glyph wall when no cues are available', () => {
const wall = [...'wansdumretoikhI'].join('\n');
assert.equal(
resolvePrimarySubtitleText({ liveText: `${wall}\ntai`, currentTimeSec: 1355, cues: null }),
'',
);
});
test('stripCanonicalFragmentLines drops a live glyph wall with no nearby canonical cues', () => {
const wall = [...'wansdumretoikhI'].join('\n');
assert.equal(
stripCanonicalFragmentLines({
liveText: `${wall}\nそれよりも ノート…`,
currentTimeSec: 1355,
cues: [],
}),
'それよりも ノート…',
);
});
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
// must explain the ghost fragments without re-surfacing next to the active line.
const cues = [
{
startTime: 1.0,
endTime: 6.0,
text: 'エネルギーはサイクル',
source: 'reconstructed-ass' as const,
animationStartTime: 0.5,
animationEndTime: 7.0,
assStyle: 'OP - JP',
},
{ startTime: 6.0, endTime: 12.0, text: '象徴的なパレード' },
];
assert.equal(
resolvePrimarySubtitleText({
liveText: 'エ\nネ\nル\nギ\nー\n象徴的なパレード',
currentTimeSec: 6.5,
cues,
}),
'象徴的なパレード',
);
});
+80 -24
View File
@@ -1,4 +1,8 @@
import type { SubtitleCue } from '../../types';
import {
removeAssControlDebrisLines,
removeLiveGlyphFragmentLines,
} from '../../core/services/ass-text';
// 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
@@ -13,6 +17,15 @@ export interface ResolvedPrimarySubtitle {
cues: SubtitleCue[];
}
function cuesUseAssSyntax(cues: readonly SubtitleCue[] | null | undefined): boolean {
return (cues ?? []).some(
(cue) =>
cue.source === 'canonical-ass' ||
cue.source === 'reconstructed-ass' ||
cue.assLayout !== undefined,
);
}
function animationSpan(cue: SubtitleCue): { start: number; end: number } {
return {
start: cue.animationStartTime ?? cue.startTime,
@@ -23,9 +36,13 @@ function animationSpan(cue: SubtitleCue): { start: number; end: number } {
function nearbyCanonicalCues(
cues: readonly SubtitleCue[] | null | undefined,
currentTimeSec: number,
includeFragmentGrids = false,
): SubtitleCue[] {
return (cues ?? []).filter((cue) => {
if (cue.source !== 'canonical-ass' && cue.source !== 'reconstructed-ass') {
if (
(cue.source !== 'canonical-ass' && cue.source !== 'reconstructed-ass') ||
(!includeFragmentGrids && cue.assLayout?.kind === 'fragment-grid')
) {
return false;
}
const span = animationSpan(cue);
@@ -37,7 +54,7 @@ function nearbyCanonicalCues(
}
function compactWhitespace(text: string): string {
return text.replace(/\s+/gu, '');
return text.normalize('NFKC').replace(/\s+/gu, '');
}
// ASS layers can encode the same visible spacing with ordinary, hard, or
@@ -47,10 +64,12 @@ function uniqueCueTexts(cues: readonly SubtitleCue[]): string[] {
const texts: string[] = [];
const seen = new Set<string>();
for (const cue of cues) {
const compactText = compactWhitespace(cue.text);
if (seen.has(compactText)) continue;
seen.add(compactText);
texts.push(cue.text);
for (const line of cue.text.split('\n')) {
const compactText = compactWhitespace(line);
if (!compactText || seen.has(compactText)) continue;
seen.add(compactText);
texts.push(line);
}
}
return texts;
}
@@ -87,23 +106,54 @@ function resolveActiveParsedPrimarySubtitle(options: {
return false;
}
const cueSegments = compactLineSegments(cue.text);
return cueSegments.length > 0 && cueSegments.every((segment) => liveSegmentSet.has(segment));
if (cueSegments.length === 0) return false;
if (cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass') {
return liveSegments.some((segment) =>
cueSegments.some((cueSegment) => cueSegment.includes(segment)),
);
}
return 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))) {
const parsedSegments = selected.flatMap((cue) =>
compactLineSegments(cue.text).map((segment) => ({
segment,
recovered: cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass',
})),
);
if (
!liveSegments.every((liveSegment) =>
parsedSegments.some(({ segment, recovered }) =>
recovered ? segment.includes(liveSegment) : segment === liveSegment,
),
)
) {
return null;
}
const texts = uniqueCueTexts(selected);
// 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;
// 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'),
);
return {
text: texts.join('\n'),
startTime: Math.min(...selected.map((cue) => cue.startTime)),
endTime: Math.max(...selected.map((cue) => cue.endTime)),
cues: selected,
startTime: Math.min(...displayCues.map((cue) => cue.startTime)),
endTime: Math.max(...displayCues.map((cue) => cue.endTime)),
cues: displayCues,
};
}
@@ -179,8 +229,9 @@ export function resolveCanonicalPrimarySubtitle(options: {
/**
* Live text with generated-animation fragment lines removed. Recording paths use this
* when full canonical substitution declined -- concurrent dialogue during an insert
* song: the dialogue is worth recording, the glyph fragments beside it are not. Returns
* the input unchanged when no canonical cue is near or nothing non-fragment remains.
* song: the dialogue is worth recording, the glyph fragments beside it are not. An
* all-fragment visual grid becomes empty; other all-matched input remains unchanged as a
* defensive fallback.
*/
export function stripCanonicalFragmentLines(options: {
liveText: string;
@@ -188,18 +239,20 @@ export function stripCanonicalFragmentLines(options: {
cues: readonly SubtitleCue[] | null | undefined;
}): string {
if (!Number.isFinite(options.currentTimeSec)) {
return options.liveText;
return removeLiveGlyphFragmentLines(options.liveText);
}
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec);
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec, true);
if (nearby.length === 0) {
return options.liveText;
return removeLiveGlyphFragmentLines(options.liveText);
}
const compactCues = nearby.map((cue) => compactWhitespace(cue.text));
const kept = options.liveText.split('\n').filter((line) => {
const compact = compactWhitespace(line);
return compact && !compactCues.some((cueText) => cueText.includes(compact));
});
return kept.length > 0 ? kept.join('\n') : options.liveText;
if (kept.length > 0) return removeLiveGlyphFragmentLines(kept.join('\n'));
if (nearby.some((cue) => cue.assLayout?.kind === 'fragment-grid')) return '';
return removeLiveGlyphFragmentLines(options.liveText);
}
export function resolvePrimarySubtitleText(options: {
@@ -207,16 +260,19 @@ export function resolvePrimarySubtitleText(options: {
currentTimeSec: number;
cues: readonly SubtitleCue[] | null | undefined;
}): string {
if (!options.liveText.trim()) {
return options.liveText;
const liveText = cuesUseAssSyntax(options.cues)
? removeAssControlDebrisLines(options.liveText)
: options.liveText;
if (!liveText.trim()) {
return liveText;
}
return (
resolveCanonicalPrimarySubtitle({
liveText: options.liveText,
liveText,
currentTimeSec: options.currentTimeSec,
cues: options.cues,
})?.text ??
resolveActiveParsedPrimarySubtitle(options)?.text ??
options.liveText
resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ??
removeLiveGlyphFragmentLines(liveText)
);
}
@@ -20,6 +20,32 @@ test('findActiveSubtitleText combines unique simultaneous parsed cues', () => {
);
});
test('findActiveSubtitleText removes duplicate lines across multiline cues', () => {
assert.equal(
findActiveSubtitleText(
[
{ startTime: 1, endTime: 3, text: 'First line\nSecond line' },
{ startTime: 1, endTime: 3, text: 'First line' },
],
2,
),
'First line\nSecond line',
);
});
test('findActiveSubtitleText removes equivalent full-width duplicate lines', () => {
assert.equal(
findActiveSubtitleText(
[
{ startTime: 1, endTime: 3, text: '真白~' },
{ startTime: 1, endTime: 3, text: '真白~' },
],
2,
),
'真白~',
);
});
test('findActiveSubtitleText collapses whitespace variants of one ASS lyric', () => {
assert.equal(
findActiveSubtitleText(
@@ -72,6 +98,22 @@ test('parsed secondary text drops a reconstructed grid of positioned sign fragme
);
});
test('parsed secondary text keeps phone translations while dropping texture payloads', () => {
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 2,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain Medium\\clip(500,40,660,150)}LLLLLLLLLLLL',
'Dialogue: 90,0:00:01.00,0:00:03.00,Default,,0,0,0,,Why did you choose Hanajo instead?',
"Dialogue: 1,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain\\fs10\\alpha&H70&}q26D'vrA;\\NE? GS\\NESLhlawEv",
"Dialogue: 3,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(582,180)\\fnSF Pro Display\\fs66}We're {\\2a0}running {\\2a1}out {\\2a0}of {\\2a1}time!\\N{\\2a0}Where {\\2a1}are {\\2a0}you {\\2a1}right {\\2a0}now?!",
].join('\n');
assert.equal(
findActiveSubtitleText(parseSubtitleCues(ass, 'phone.ass'), 2),
"Why did you choose Hanajo instead?\nWe're running out of time!\nWhere are you right now?!",
);
});
test('parsed secondary lyrics keep explicit ASS vertical order when durations alternate', () => {
const lyric = (options: { start: string; end: string; style: string; y: number; text: string }) =>
`Dialogue: 0,0:00:${options.start},0:00:${options.end},${options.style},,0,0,0,fx,{\\move(100,${options.y},120,${options.y})\\t(0,200,\\fscx110)}${options.text}\\N{\\p1}m 0 0 l 0 5`;
@@ -144,6 +186,30 @@ test('findActiveSubtitleText keeps a canonical ASS cue for its generated animati
assert.equal(findActiveSubtitleText([poof], 1111.59), '');
});
test('findActiveSubtitleText advances when the next canonical lyric animation starts', () => {
const cues = [
{
startTime: 121.73,
endTime: 124.1,
text: 'Torn at the seams, a sound pours out',
source: 'canonical-ass' as const,
animationStartTime: 121.4,
animationEndTime: 124.1,
},
{
startTime: 124.13,
endTime: 126.38,
text: 'Its silent, yet spreads all around',
source: 'canonical-ass' as const,
animationStartTime: 123.8,
animationEndTime: 126.38,
},
];
assert.equal(findActiveSubtitleText(cues, 123.79), cues[0]!.text);
assert.equal(findActiveSubtitleText(cues, 123.8), cues[1]!.text);
});
test('ASS fragment karaoke stays separated by style with authored word spacing', () => {
const lineEvents = (
style: string,
@@ -380,6 +446,125 @@ test('secondary track controller falls back to live mpv text without a readable
assert.deepEqual(broadcasts, ['live fallback']);
});
test('secondary ASS live fallback drops malformed control debris', async () => {
const broadcasts: string[] = [];
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
if (name === 'path') return '/media/video.mkv';
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async () => ({ path: '/subs/english.ass', sourceKey: 'english' }),
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => [],
setCurrentSecondaryText: () => {},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
await controller.refresh();
broadcasts.length = 0;
controller.handleLiveText('Visible line\n\\\n{\\fr0');
assert.deepEqual(broadcasts, ['Visible line']);
});
test('secondary SRT live fallback preserves text that resembles ASS control debris', async () => {
const broadcasts: string[] = [];
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
if (name === 'path') return '/media/video.mkv';
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async () => ({ path: '/subs/english.srt', sourceKey: 'english' }),
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => [],
setCurrentSecondaryText: () => {},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
await controller.refresh();
broadcasts.length = 0;
controller.handleLiveText('Visible line\n\\\n{\\fr0');
assert.deepEqual(broadcasts, ['Visible line\n\\\n{\\fr0']);
});
test('secondary disconnect clears stale ASS fallback sanitization state', async () => {
let connected = true;
const broadcasts: string[] = [];
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
if (name === 'path') return '/media/video.mkv';
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async () => ({ path: '/subs/english.ass', sourceKey: 'english' }),
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => [],
setCurrentSecondaryText: () => {},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
await controller.refresh();
connected = false;
await controller.refresh();
broadcasts.length = 0;
controller.handleLiveText('Visible line\n\\\n{\\fr0');
assert.deepEqual(broadcasts, ['Visible line\n\\\n{\\fr0']);
});
test('secondary source refresh failure clears stale ASS fallback sanitization state', async () => {
let resolveCalls = 0;
const broadcasts: string[] = [];
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
if (name === 'path') return '/media/video.mkv';
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async () => {
resolveCalls += 1;
if (resolveCalls === 1) {
return { path: '/subs/english.ass', sourceKey: 'english' };
}
throw new Error('source refresh failed');
},
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => [],
setCurrentSecondaryText: () => {},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
await controller.refresh();
await controller.refresh();
broadcasts.length = 0;
controller.handleLiveText('Visible line\n\\\n{\\fr0');
assert.deepEqual(broadcasts, ['Visible line\n\\\n{\\fr0']);
});
test('secondary track controller reuses parsed cues for an unchanged embedded track', async () => {
let resolveCalls = 0;
let parseCalls = 0;
@@ -471,3 +656,36 @@ test('secondary track controller ignores and cleans up a refresh invalidated by
assert.equal(parseCalls, 0);
assert.equal(cleanupCalls, 1);
});
test('secondary live fallback suppresses a per-glyph typesetting wall', async () => {
let currentText = '';
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
if (name === 'path') return '/mnt/nas/video.mkv';
if (name === 'secondary-sub-delay') return 0;
return null;
},
}),
getCurrentTimePos: () => 1355,
// Network-mounted media: embedded extraction is skipped, so no parsed cues exist.
resolveSubtitleSource: async () => null,
loadSubtitleSourceText: async () => '',
parseSubtitleCues,
setCurrentSecondaryText: (text) => {
currentText = text;
},
broadcastSecondaryText: () => {},
});
await controller.refresh();
const wall = [...'wansdumretoikhI'].join('\n');
controller.handleLiveText(`${wall}\ntai`);
assert.equal(currentText, '');
controller.handleLiveText(`${wall}\nそれよりも ノート…`);
assert.equal(currentText, 'それよりも ノート…');
});
+50 -10
View File
@@ -1,5 +1,9 @@
import type { SubtitleCue } from '../../types/subtitle';
import { flattenedSecondarySubtitleLineIdentity } from '../../core/services/secondary-subtitle-line-identity';
import {
removeAssControlDebrisLines,
removeLiveGlyphFragmentLines,
} from '../../core/services/ass-text';
type SecondarySubtitleMpvClient = {
connected?: boolean;
@@ -23,6 +27,11 @@ type SecondarySubtitleSourceInput = {
const DEFAULT_REFRESH_DELAY_MS = 500;
function sourceUsesAssSyntax(source: string): boolean {
const sourceWithoutQuery = source.split(/[?#]/u, 1)[0] ?? '';
return /\.(?:ass|ssa)$/iu.test(sourceWithoutQuery);
}
function finiteNumber(value: unknown, fallback = 0): number {
const number = typeof value === 'number' ? value : Number(value);
return Number.isFinite(number) ? number : fallback;
@@ -82,7 +91,26 @@ export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds
(cue) =>
cue.source === 'canonical-ass' && cue.startTime <= timeSeconds && cue.endTime > timeSeconds,
);
const selectedCanonical = new Set<SubtitleCue>(authoredCanonical);
const enteringCanonical = cues.filter(
(cue) =>
cue.source === 'canonical-ass' &&
(cue.animationStartTime ?? cue.startTime) <= timeSeconds &&
cue.startTime > timeSeconds &&
(cue.animationEndTime ?? cue.endTime) > timeSeconds,
);
const nextAuthoredStart = enteringCanonical.reduce(
(earliest, cue) => Math.min(earliest, cue.startTime),
Infinity,
);
// Generated lyrics can begin drawing before their canonical Comment timing. Once that
// entrance starts, replace a preceding lyric that ends before the new authored span;
// genuinely concurrent subtitles that continue through the new span stay selected.
const selectedCanonical = new Set<SubtitleCue>([
...authoredCanonical.filter(
(cue) => enteringCanonical.length === 0 || cue.endTime > nextAuthoredStart,
),
...enteringCanonical,
]);
if (selectedCanonical.size === 0) {
const animatedCanonical = cues.filter(
(cue) =>
@@ -153,15 +181,17 @@ export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds
activeCues.sort(compareAuthoredSubtitleOrder);
for (const { cue } of activeCues) {
const text = cue.text.trim();
const compactText = text.replace(/\s+/gu, '');
if (!compactText || seenExact.has(compactText)) continue;
seenExact.add(compactText);
for (const line of cue.text.split('\n')) {
const text = line.trim();
const compactText = text.normalize('NFKC').replace(/\s+/gu, '');
if (!compactText || seenExact.has(compactText)) continue;
seenExact.add(compactText);
const flattenedIdentity = flattenedSecondarySubtitleLineIdentity(text);
if (flattenedIdentity && seenFlattened.has(flattenedIdentity)) continue;
if (flattenedIdentity) seenFlattened.add(flattenedIdentity);
activeText.push(text);
const flattenedIdentity = flattenedSecondarySubtitleLineIdentity(text);
if (flattenedIdentity && seenFlattened.has(flattenedIdentity)) continue;
if (flattenedIdentity) seenFlattened.add(flattenedIdentity);
activeText.push(text);
}
}
return activeText.join('\n');
}
@@ -182,6 +212,7 @@ export function createSecondarySubtitleTrackController(deps: {
let parsedCues: SubtitleCue[] | null = null;
let parsedSourceKey: string | null = null;
let parsedTrackIdentity: string | null = null;
let activeSourceUsesAssSyntax = false;
let secondaryDelaySeconds = 0;
let lastLiveText = '';
let lastBroadcastText: string | null = null;
@@ -211,6 +242,7 @@ export function createSecondarySubtitleTrackController(deps: {
const generation = ++refreshGeneration;
const client = deps.getMpvClient();
if (!client?.connected) {
activeSourceUsesAssSyntax = false;
useLiveFallback();
return;
}
@@ -227,6 +259,7 @@ export function createSecondarySubtitleTrackController(deps: {
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw.trim() : '';
if (!videoPath || secondarySid === null || secondarySid === 'no') {
activeSourceUsesAssSyntax = false;
useLiveFallback();
return;
}
@@ -248,11 +281,14 @@ export function createSecondarySubtitleTrackController(deps: {
});
if (generation !== refreshGeneration) return;
if (!resolvedSource) {
activeSourceUsesAssSyntax = false;
deps.logDebug?.('[secondary-subtitle-track] selected source is not readable');
useLiveFallback();
return;
}
activeSourceUsesAssSyntax = sourceUsesAssSyntax(resolvedSource.path);
if (resolvedSource.sourceKey === parsedSourceKey && parsedCues) {
parsedTrackIdentity = selectedTrackIdentity;
publish(resolveAtTime(deps.getCurrentTimePos()));
@@ -274,6 +310,7 @@ export function createSecondarySubtitleTrackController(deps: {
publish(resolveAtTime(deps.getCurrentTimePos()));
} catch (error) {
if (generation !== refreshGeneration) return;
activeSourceUsesAssSyntax = false;
deps.logWarn?.('[secondary-subtitle-track] failed to parse selected source', error);
useLiveFallback();
} finally {
@@ -296,6 +333,7 @@ export function createSecondarySubtitleTrackController(deps: {
parsedCues = null;
parsedSourceKey = null;
parsedTrackIdentity = null;
activeSourceUsesAssSyntax = false;
secondaryDelaySeconds = 0;
lastLiveText = '';
publish('');
@@ -305,7 +343,9 @@ export function createSecondarySubtitleTrackController(deps: {
refresh,
scheduleRefresh,
handleLiveText(text: string): void {
lastLiveText = text;
lastLiveText = removeLiveGlyphFragmentLines(
activeSourceUsesAssSyntax ? removeAssControlDebrisLines(text) : text,
);
publish(resolveAtTime(deps.getCurrentTimePos()));
},
handleTimePos(timeSeconds: number): void {
@@ -157,14 +157,16 @@ test('subtitle prefetch runtime does not extract internal subtitle tracks from r
assert.equal(extracted, false);
});
test('subtitle prefetch runtime does not extract internal subtitle tracks from network mounts', async () => {
test('subtitle prefetch runtime extracts internal subtitle tracks from network-mounted media', async () => {
let extracted = false;
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
getFfmpegPath: () => 'ffmpeg-custom',
isRemoteMediaPath: async (videoPath) => videoPath.startsWith('/Volumes/jellyfin/'),
extractInternalSubtitleTrack: async () => {
extracted = true;
return null;
return {
path: '/tmp/subminer-sidebar-123/track_7.ass',
cleanup: async () => {},
};
},
});
@@ -181,8 +183,8 @@ test('subtitle prefetch runtime does not extract internal subtitle tracks from n
videoPath: '/Volumes/jellyfin/movie.mkv',
});
assert.equal(resolved, null);
assert.equal(extracted, false);
assert.equal(resolved?.path, '/tmp/subminer-sidebar-123/track_7.ass');
assert.equal(extracted, true);
});
test('subtitle prefetch refresh logs a warning when source resolution throws', async () => {
@@ -17,8 +17,6 @@ type ActiveSubtitleSidebarSource = {
cleanup?: () => Promise<void>;
};
type RemoteMediaPathDetector = (mediaPath: string) => boolean | Promise<boolean>;
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
@@ -88,7 +86,6 @@ function getActiveSubtitleTrack(
export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
getFfmpegPath: () => string;
isRemoteMediaPath?: RemoteMediaPathDetector;
extractInternalSubtitleTrack: (
ffmpegPath: string,
videoPath: string,
@@ -129,8 +126,10 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
return { path: externalFilename, sourceKey: externalFilename };
}
const isRemoteMediaPath = deps.isRemoteMediaPath ?? isRemoteMediaUrl;
if (await isRemoteMediaPath(input.videoPath)) {
// Network-mounted files extract like local ones: demuxing reads the whole
// container (~10s/GB on gigabit), which a LAN handles alongside playback.
// Only true remote URLs have no on-disk container to demux.
if (isRemoteMediaUrl(input.videoPath)) {
deps.logDebug?.('[subtitle-prefetch] skipping internal subtitle extraction for remote media');
return null;
}