fix(subtitles): only strip ASS control debris for ASS sources

- Skip control-debris stripping for primary/secondary live text when cues or source aren't ASS/SSA, so SRT lines that merely resemble ASS override tags survive
- Track active source's ASS-ness in the secondary controller and gate handleLiveText's cleanup on it
This commit is contained in:
2026-08-23 16:54:43 -07:00
parent c87dcd6239
commit faab084588
4 changed files with 84 additions and 6 deletions
+20 -1
View File
@@ -218,16 +218,35 @@ test('resolvePrimarySubtitleText uses fragment grids only to account for live si
});
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: null,
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: '次のセリフ',
+12 -1
View File
@@ -14,6 +14,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,
@@ -235,7 +244,9 @@ export function resolvePrimarySubtitleText(options: {
currentTimeSec: number;
cues: readonly SubtitleCue[] | null | undefined;
}): string {
const liveText = removeAssControlDebrisLines(options.liveText);
const liveText = cuesUseAssSyntax(options.cues)
? removeAssControlDebrisLines(options.liveText)
: options.liveText;
if (!liveText.trim()) {
return liveText;
}
@@ -406,23 +406,60 @@ test('secondary track controller falls back to live mpv text without a readable
assert.deepEqual(broadcasts, ['live fallback']);
});
test('secondary live fallback drops malformed ASS control debris', () => {
test('secondary ASS live fallback drops malformed control debris', async () => {
const broadcasts: string[] = [];
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => null,
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 () => null,
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 track controller reuses parsed cues for an unchanged embedded track', async () => {
let resolveCalls = 0;
let parseCalls = 0;
+12 -1
View File
@@ -24,6 +24,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;
@@ -185,6 +190,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;
@@ -230,6 +236,7 @@ export function createSecondarySubtitleTrackController(deps: {
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw.trim() : '';
if (!videoPath || secondarySid === null || secondarySid === 'no') {
activeSourceUsesAssSyntax = false;
useLiveFallback();
return;
}
@@ -251,11 +258,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()));
@@ -299,6 +309,7 @@ export function createSecondarySubtitleTrackController(deps: {
parsedCues = null;
parsedSourceKey = null;
parsedTrackIdentity = null;
activeSourceUsesAssSyntax = false;
secondaryDelaySeconds = 0;
lastLiveText = '';
publish('');
@@ -308,7 +319,7 @@ export function createSecondarySubtitleTrackController(deps: {
refresh,
scheduleRefresh,
handleLiveText(text: string): void {
lastLiveText = removeAssControlDebrisLines(text);
lastLiveText = activeSourceUsesAssSyntax ? removeAssControlDebrisLines(text) : text;
publish(resolveAtTime(deps.getCurrentTimePos()));
},
handleTimePos(timeSeconds: number): void {