From 35ca2afc6ff70b51c3d087f8dbaaf7de99ff1318 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 6 Jul 2026 01:09:06 -0700 Subject: [PATCH] fix(overlay): collapse karaoke syllable spam in secondary subtitles (#139) --- changes/secondary-sub-karaoke-collapse.md | 4 ++ src/renderer/style.css | 4 ++ src/renderer/subtitle-render.test.ts | 41 ++++++++++++++++++++ src/renderer/subtitle-render.ts | 47 ++++++++++++++++++----- 4 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 changes/secondary-sub-karaoke-collapse.md diff --git a/changes/secondary-sub-karaoke-collapse.md b/changes/secondary-sub-karaoke-collapse.md new file mode 100644 index 00000000..855550fb --- /dev/null +++ b/changes/secondary-sub-karaoke-collapse.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed secondary subtitles stacking dozens of one-syllable lines down the screen during karaoke-typeset openings/endings, which made the hover-pause band cover the whole video: karaoke-like event spam is now collapsed into a single deduped line, and the secondary subtitle area is height-capped so it always stays a strip at the top. diff --git a/src/renderer/style.css b/src/renderer/style.css index d9c3b480..c028ea11 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -1842,6 +1842,10 @@ body.layer-modal #overlay { text-align: center; font-size: 24px; line-height: 1.5; + /* Backstop: pathological tracks (karaoke typesetting, sign spam) must never grow + the hover-pause band beyond a top strip. ~4 lines at line-height 1.5. */ + max-height: 6em; + overflow: hidden; color: #ffffff; -webkit-text-stroke: 0.45px rgba(0, 0, 0, 0.7); paint-order: stroke fill; diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index cd8f6506..00383311 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -11,6 +11,7 @@ import { getFrequencyRankLabelForToken, getJlptLevelLabelForToken, normalizeSubtitle, + prepareSecondarySubtitleLines, sanitizeSubtitleHoverTokenColor, shouldRenderTokenizedSubtitle, } from './subtitle-render.js'; @@ -1404,3 +1405,43 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo /body\.layer-visible\s+#secondarySubContainer\s*\{[^}]*display:\s*none/i, ); }); + +test('prepareSecondarySubtitleLines collapses karaoke syllable spam into one deduped line', () => { + // Karaoke-typeset OP/ED: one ASS event per syllable, duplicated across layers, + // joined with \N by mpv's secondary-sub-text. + const karaoke = ['ya', 'This', 'ya', 'This', 'ya', 'This', 'no', 'ma', 'ups', 'ma', 'ups'].join( + '\\N', + ); + + assert.deepEqual(prepareSecondarySubtitleLines(karaoke), ['ya This no ma ups']); +}); + +test('prepareSecondarySubtitleLines keeps normal dialogue lines intact', () => { + const dialogue = ' I never expected this. \\N\\N But here we are. '; + + assert.deepEqual(prepareSecondarySubtitleLines(dialogue), [ + 'I never expected this.', + 'But here we are.', + ]); +}); + +test('prepareSecondarySubtitleLines does not collapse many long simultaneous lines', () => { + const lines = Array.from({ length: 9 }, (_, i) => `This is a full sentence number ${i}.`); + + assert.deepEqual(prepareSecondarySubtitleLines(lines.join('\\N')), lines); +}); + +test('prepareSecondarySubtitleLines strips ASS override tags and handles empty input', () => { + assert.deepEqual(prepareSecondarySubtitleLines('{\\an8}Sign text'), ['Sign text']); + assert.deepEqual(prepareSecondarySubtitleLines(''), []); + assert.deepEqual(prepareSecondarySubtitleLines('{\\an8}'), []); +}); + +test('secondary subtitle root CSS caps height so hover-pause band stays a top strip', () => { + const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css'); + const cssText = fs.readFileSync(srcCssPath, 'utf-8'); + + const secondaryRootBlock = extractClassBlock(cssText, '#secondarySubRoot'); + assert.match(secondaryRootBlock, /max-height:\s*6em;/); + assert.match(secondaryRootBlock, /overflow:\s*hidden;/); +}); diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index a1c71429..181aee4e 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -652,6 +652,43 @@ function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void root.appendChild(fragment); } +// Karaoke-typeset OP/ED tracks emit one ASS event per syllable (duplicated across +// layers), and mpv's secondary-sub-text joins every active event with newlines — +// rendering those verbatim stacks dozens of tiny lines down the screen and turns the +// hover-pause band into a full-screen trap. +const KARAOKE_MIN_LINE_COUNT = 8; +const KARAOKE_MAX_MEDIAN_LINE_LENGTH = 4; + +function isKaraokeLikeLineSet(lines: string[]): boolean { + if (lines.length < KARAOKE_MIN_LINE_COUNT) return false; + const lengths = lines.map((line) => line.length).sort((a, b) => a - b); + const median = lengths[Math.floor(lengths.length / 2)] ?? 0; + return median <= KARAOKE_MAX_MEDIAN_LINE_LENGTH; +} + +export function prepareSecondarySubtitleLines(text: string): string[] { + const normalized = normalizeSubtitle(text, true, false); + + if (!normalized) return []; + + const lines = normalized + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + if (!isKaraokeLikeLineSet(lines)) { + return lines; + } + + const seen = new Set(); + const unique: string[] = []; + for (const line of lines) { + if (seen.has(line)) continue; + seen.add(line); + unique.push(line); + } + return [unique.join(' ')]; +} + export function createSubtitleRenderer(ctx: RendererContext) { let lastPrimarySubtitleRenderKey: string | null = null; let lastPrimarySubtitleNormalizedText: string | null = null; @@ -750,15 +787,7 @@ export function createSubtitleRenderer(ctx: RendererContext) { ctx.dom.secondarySubRoot.replaceChildren(); if (!text) return; - const normalized = text - .replace(/\\N/g, '\n') - .replace(/\\n/g, '\n') - .replace(/\{[^}]*\}/g, '') - .trim(); - - if (!normalized) return; - - const lines = normalized.split('\n'); + const lines = prepareSecondarySubtitleLines(text); for (let i = 0; i < lines.length; i += 1) { const line = lines[i]; if (line) {