From faab08458818f15c1250e53aff25f09a9fbaeb2c Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 23 Aug 2026 16:54:43 -0700 Subject: [PATCH] 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 --- .../runtime/primary-subtitle-text.test.ts | 21 ++++++++- src/main/runtime/primary-subtitle-text.ts | 13 +++++- .../runtime/secondary-subtitle-track.test.ts | 43 +++++++++++++++++-- src/main/runtime/secondary-subtitle-track.ts | 13 +++++- 4 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/main/runtime/primary-subtitle-text.test.ts b/src/main/runtime/primary-subtitle-text.test.ts index 085fd821..5d9cb98a 100644 --- a/src/main/runtime/primary-subtitle-text.test.ts +++ b/src/main/runtime/primary-subtitle-text.test.ts @@ -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: '次のセリフ', diff --git a/src/main/runtime/primary-subtitle-text.ts b/src/main/runtime/primary-subtitle-text.ts index 91ea6deb..c94c2e65 100644 --- a/src/main/runtime/primary-subtitle-text.ts +++ b/src/main/runtime/primary-subtitle-text.ts @@ -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; } diff --git a/src/main/runtime/secondary-subtitle-track.test.ts b/src/main/runtime/secondary-subtitle-track.test.ts index d0769801..a9e04d1f 100644 --- a/src/main/runtime/secondary-subtitle-track.test.ts +++ b/src/main/runtime/secondary-subtitle-track.test.ts @@ -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; diff --git a/src/main/runtime/secondary-subtitle-track.ts b/src/main/runtime/secondary-subtitle-track.ts index e3443aa2..f64a5b52 100644 --- a/src/main/runtime/secondary-subtitle-track.ts +++ b/src/main/runtime/secondary-subtitle-track.ts @@ -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 {