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', () => { 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( assert.equal(
resolvePrimarySubtitleText({ resolvePrimarySubtitleText({
liveText: 'Visible line\n\\\n{\\fr0', liveText: 'Visible line\n\\\n{\\fr0',
currentTimeSec: 2, currentTimeSec: 2,
cues: null, cues,
}), }),
'Visible line', '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', () => { test('resolvePrimarySubtitleText keeps a fresh line starting just after the animation ended', () => {
const text = resolvePrimarySubtitleText({ const text = resolvePrimarySubtitleText({
liveText: '次のセリフ', liveText: '次のセリフ',
+12 -1
View File
@@ -14,6 +14,15 @@ export interface ResolvedPrimarySubtitle {
cues: SubtitleCue[]; 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 } { function animationSpan(cue: SubtitleCue): { start: number; end: number } {
return { return {
start: cue.animationStartTime ?? cue.startTime, start: cue.animationStartTime ?? cue.startTime,
@@ -235,7 +244,9 @@ export function resolvePrimarySubtitleText(options: {
currentTimeSec: number; currentTimeSec: number;
cues: readonly SubtitleCue[] | null | undefined; cues: readonly SubtitleCue[] | null | undefined;
}): string { }): string {
const liveText = removeAssControlDebrisLines(options.liveText); const liveText = cuesUseAssSyntax(options.cues)
? removeAssControlDebrisLines(options.liveText)
: options.liveText;
if (!liveText.trim()) { if (!liveText.trim()) {
return liveText; return liveText;
} }
@@ -406,23 +406,60 @@ test('secondary track controller falls back to live mpv text without a readable
assert.deepEqual(broadcasts, ['live fallback']); 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 broadcasts: string[] = [];
const controller = createSecondarySubtitleTrackController({ 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, getCurrentTimePos: () => 2,
resolveSubtitleSource: async () => null, resolveSubtitleSource: async () => ({ path: '/subs/english.ass', sourceKey: 'english' }),
loadSubtitleSourceText: async () => '', loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => [], parseSubtitleCues: () => [],
setCurrentSecondaryText: () => {}, setCurrentSecondaryText: () => {},
broadcastSecondaryText: (text) => broadcasts.push(text), broadcastSecondaryText: (text) => broadcasts.push(text),
}); });
await controller.refresh();
broadcasts.length = 0;
controller.handleLiveText('Visible line\n\\\n{\\fr0'); controller.handleLiveText('Visible line\n\\\n{\\fr0');
assert.deepEqual(broadcasts, ['Visible line']); 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 () => { test('secondary track controller reuses parsed cues for an unchanged embedded track', async () => {
let resolveCalls = 0; let resolveCalls = 0;
let parseCalls = 0; let parseCalls = 0;
+12 -1
View File
@@ -24,6 +24,11 @@ type SecondarySubtitleSourceInput = {
const DEFAULT_REFRESH_DELAY_MS = 500; 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 { function finiteNumber(value: unknown, fallback = 0): number {
const number = typeof value === 'number' ? value : Number(value); const number = typeof value === 'number' ? value : Number(value);
return Number.isFinite(number) ? number : fallback; return Number.isFinite(number) ? number : fallback;
@@ -185,6 +190,7 @@ export function createSecondarySubtitleTrackController(deps: {
let parsedCues: SubtitleCue[] | null = null; let parsedCues: SubtitleCue[] | null = null;
let parsedSourceKey: string | null = null; let parsedSourceKey: string | null = null;
let parsedTrackIdentity: string | null = null; let parsedTrackIdentity: string | null = null;
let activeSourceUsesAssSyntax = false;
let secondaryDelaySeconds = 0; let secondaryDelaySeconds = 0;
let lastLiveText = ''; let lastLiveText = '';
let lastBroadcastText: string | null = null; let lastBroadcastText: string | null = null;
@@ -230,6 +236,7 @@ export function createSecondarySubtitleTrackController(deps: {
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw.trim() : ''; const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw.trim() : '';
if (!videoPath || secondarySid === null || secondarySid === 'no') { if (!videoPath || secondarySid === null || secondarySid === 'no') {
activeSourceUsesAssSyntax = false;
useLiveFallback(); useLiveFallback();
return; return;
} }
@@ -251,11 +258,14 @@ export function createSecondarySubtitleTrackController(deps: {
}); });
if (generation !== refreshGeneration) return; if (generation !== refreshGeneration) return;
if (!resolvedSource) { if (!resolvedSource) {
activeSourceUsesAssSyntax = false;
deps.logDebug?.('[secondary-subtitle-track] selected source is not readable'); deps.logDebug?.('[secondary-subtitle-track] selected source is not readable');
useLiveFallback(); useLiveFallback();
return; return;
} }
activeSourceUsesAssSyntax = sourceUsesAssSyntax(resolvedSource.path);
if (resolvedSource.sourceKey === parsedSourceKey && parsedCues) { if (resolvedSource.sourceKey === parsedSourceKey && parsedCues) {
parsedTrackIdentity = selectedTrackIdentity; parsedTrackIdentity = selectedTrackIdentity;
publish(resolveAtTime(deps.getCurrentTimePos())); publish(resolveAtTime(deps.getCurrentTimePos()));
@@ -299,6 +309,7 @@ export function createSecondarySubtitleTrackController(deps: {
parsedCues = null; parsedCues = null;
parsedSourceKey = null; parsedSourceKey = null;
parsedTrackIdentity = null; parsedTrackIdentity = null;
activeSourceUsesAssSyntax = false;
secondaryDelaySeconds = 0; secondaryDelaySeconds = 0;
lastLiveText = ''; lastLiveText = '';
publish(''); publish('');
@@ -308,7 +319,7 @@ export function createSecondarySubtitleTrackController(deps: {
refresh, refresh,
scheduleRefresh, scheduleRefresh,
handleLiveText(text: string): void { handleLiveText(text: string): void {
lastLiveText = removeAssControlDebrisLines(text); lastLiveText = activeSourceUsesAssSyntax ? removeAssControlDebrisLines(text) : text;
publish(resolveAtTime(deps.getCurrentTimePos())); publish(resolveAtTime(deps.getCurrentTimePos()));
}, },
handleTimePos(timeSeconds: number): void { handleTimePos(timeSeconds: number): void {