From 1717d2d3f26f3f0f923f4224d2feafc1a567cf65 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 23 Aug 2026 20:33:13 -0700 Subject: [PATCH] fix(subtitles): suppress per-glyph typesetting walls in live subtitle text When embedded-track extraction is skipped (network-mounted media), live mpv text during per-glyph typeset karaoke is a wall of simultaneous one-glyph lines plus the syllable being typed. No parsed cues exist to substitute, so the wall reached both overlays and recording verbatim. Detect bursts of many single-glyph lines in the live fallback paths and drop them with their short syllable companions, keeping concurrent dialogue lines. --- .../dedupe-embedded-subtitle-extraction.md | 1 + docs/architecture/subtitle-overlay-priming.md | 5 +++ src/core/services/ass-text.test.ts | 16 +++++++++ src/core/services/ass-text.ts | 18 ++++++++++ .../runtime/primary-subtitle-text.test.ts | 20 +++++++++++ src/main/runtime/primary-subtitle-text.ts | 16 +++++---- .../runtime/secondary-subtitle-track.test.ts | 33 +++++++++++++++++++ src/main/runtime/secondary-subtitle-track.ts | 9 +++-- 8 files changed, 110 insertions(+), 8 deletions(-) diff --git a/changes/dedupe-embedded-subtitle-extraction.md b/changes/dedupe-embedded-subtitle-extraction.md index 413e489c..49c05737 100644 --- a/changes/dedupe-embedded-subtitle-extraction.md +++ b/changes/dedupe-embedded-subtitle-extraction.md @@ -2,3 +2,4 @@ type: fixed area: subtitles - Prevented embedded subtitle parsing from starving network playback: mounted SMB/NFS media now uses deduplicated mpv live text, while duplicate extraction requests for local media share one ffmpeg process. +- Live subtitle text from per-glyph typeset karaoke (network-mounted media without parsed cues) no longer shows a wall of scattered letters in the overlays; the glyph wall and its typed-syllable fragments are suppressed while concurrent dialogue lines remain. diff --git a/docs/architecture/subtitle-overlay-priming.md b/docs/architecture/subtitle-overlay-priming.md index 36d9b6e4..c4e4097d 100644 --- a/docs/architecture/subtitle-overlay-priming.md +++ b/docs/architecture/subtitle-overlay-priming.md @@ -100,6 +100,11 @@ coming and prefetching would otherwise idle for the rest of the cue. - `secondary-sub-text` remains the immediate fallback, so unreadable subtitle sources, remote URLs, and files on network mounts still appear without waiting for file resolution. Embedded-track extraction is skipped for those sources to avoid competing with playback for network bandwidth. +- The live fallback also suppresses per-glyph typesetting walls: when many simultaneous + one-glyph lines are present (generated karaoke lettering flattened into live text), those + lines and their short syllable companions are dropped while concurrent dialogue lines stay. + This covers network-mounted media, where embedded-track extraction is skipped and no parsed + cues exist to substitute. - Parsed secondary text and the live fallback remove exact repeated lines at any length. A flattened-line identity also removes long dialogue/sign repetitions that differ only in whitespace or terminal punctuation, while distinct simultaneous short lines remain separate. diff --git a/src/core/services/ass-text.test.ts b/src/core/services/ass-text.test.ts index c16ada9a..a24a3db5 100644 --- a/src/core/services/ass-text.test.ts +++ b/src/core/services/ass-text.test.ts @@ -10,6 +10,7 @@ import { isAssTemporalCommand, normalizePlainSubtitleText, parseAssEffectField, + removeLiveGlyphFragmentLines, removeAssControlDebrisLines, } from './ass-text'; @@ -202,3 +203,18 @@ test('isAnimatedAssEffectKind covers the stock animated effects only', () => { assert.equal(isAnimatedAssEffectKind('other'), false); assert.equal(isAnimatedAssEffectKind('none'), false); }); + +test('removeLiveGlyphFragmentLines drops a per-glyph typesetting wall and its syllable', () => { + const wall = [...'wansdumretoikhI'].join('\n'); + assert.equal(removeLiveGlyphFragmentLines(`${wall}\ntai`), ''); +}); + +test('removeLiveGlyphFragmentLines keeps concurrent dialogue beside a glyph wall', () => { + const wall = [...'wansdumretoikhI'].join('\n'); + assert.equal(removeLiveGlyphFragmentLines(`${wall}\nそれよりも ノート…`), 'それよりも ノート…'); +}); + +test('removeLiveGlyphFragmentLines leaves ordinary short lines alone', () => { + const text = 'え\nはい。\nそうだな'; + assert.equal(removeLiveGlyphFragmentLines(text), text); +}); diff --git a/src/core/services/ass-text.ts b/src/core/services/ass-text.ts index a405b312..dac3f4de 100644 --- a/src/core/services/ass-text.ts +++ b/src/core/services/ass-text.ts @@ -108,6 +108,24 @@ export function removeAssControlDebrisLines(text: string): string { .join('\n'); } +const MIN_GLYPH_BURST_LINES = 6; +const MAX_GLYPH_BURST_COMPANION_GLYPHS = 3; + +/** + * Per-glyph karaoke typesetting flattened into live text becomes a wall of + * single-character lines plus the short syllable currently being typed. No authored + * subtitle stacks this many one-glyph lines at once, so when the wall is present drop + * it and its short companion fragments while keeping any concurrent dialogue line. + */ +export function removeLiveGlyphFragmentLines(text: string): string { + const lines = text.split('\n'); + const singleGlyphLines = lines.filter((line) => [...line.trim()].length === 1).length; + if (singleGlyphLines < MIN_GLYPH_BURST_LINES) return text; + return lines + .filter((line) => [...line.trim()].length > MAX_GLYPH_BURST_COMPANION_GLYPHS) + .join('\n'); +} + export interface NormalizePlainSubtitleTextOptions { /** Fold every line break into a single space. */ collapseLineBreaks?: boolean; diff --git a/src/main/runtime/primary-subtitle-text.test.ts b/src/main/runtime/primary-subtitle-text.test.ts index 5d9cb98a..2ba41f6e 100644 --- a/src/main/runtime/primary-subtitle-text.test.ts +++ b/src/main/runtime/primary-subtitle-text.test.ts @@ -477,3 +477,23 @@ 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: [], + }), + 'それよりも ノート…', + ); +}); diff --git a/src/main/runtime/primary-subtitle-text.ts b/src/main/runtime/primary-subtitle-text.ts index c94c2e65..431487b9 100644 --- a/src/main/runtime/primary-subtitle-text.ts +++ b/src/main/runtime/primary-subtitle-text.ts @@ -1,5 +1,8 @@ import type { SubtitleCue } from '../../types'; -import { removeAssControlDebrisLines } from '../../core/services/ass-text'; +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 @@ -224,19 +227,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, 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)); }); - if (kept.length > 0) return kept.join('\n'); - return nearby.some((cue) => cue.assLayout?.kind === 'fragment-grid') ? '' : 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: { @@ -257,6 +261,6 @@ export function resolvePrimarySubtitleText(options: { cues: options.cues, })?.text ?? resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ?? - liveText + removeLiveGlyphFragmentLines(liveText) ); } diff --git a/src/main/runtime/secondary-subtitle-track.test.ts b/src/main/runtime/secondary-subtitle-track.test.ts index 648d727e..f15cd6d8 100644 --- a/src/main/runtime/secondary-subtitle-track.test.ts +++ b/src/main/runtime/secondary-subtitle-track.test.ts @@ -581,3 +581,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, 'それよりも ノート…'); +}); diff --git a/src/main/runtime/secondary-subtitle-track.ts b/src/main/runtime/secondary-subtitle-track.ts index 09f47962..412da142 100644 --- a/src/main/runtime/secondary-subtitle-track.ts +++ b/src/main/runtime/secondary-subtitle-track.ts @@ -1,6 +1,9 @@ import type { SubtitleCue } from '../../types/subtitle'; import { flattenedSecondarySubtitleLineIdentity } from '../../core/services/secondary-subtitle-line-identity'; -import { removeAssControlDebrisLines } from '../../core/services/ass-text'; +import { + removeAssControlDebrisLines, + removeLiveGlyphFragmentLines, +} from '../../core/services/ass-text'; type SecondarySubtitleMpvClient = { connected?: boolean; @@ -320,7 +323,9 @@ export function createSecondarySubtitleTrackController(deps: { refresh, scheduleRefresh, handleLiveText(text: string): void { - lastLiveText = activeSourceUsesAssSyntax ? removeAssControlDebrisLines(text) : text; + lastLiveText = removeLiveGlyphFragmentLines( + activeSourceUsesAssSyntax ? removeAssControlDebrisLines(text) : text, + ); publish(resolveAtTime(deps.getCurrentTimePos())); }, handleTimePos(timeSeconds: number): void {