mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-07 13:08:54 -07:00
fix(overlay): collapse karaoke syllable spam in secondary subtitles (#139)
This commit is contained in:
@@ -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.
|
||||||
@@ -1842,6 +1842,10 @@ body.layer-modal #overlay {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
line-height: 1.5;
|
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;
|
color: #ffffff;
|
||||||
-webkit-text-stroke: 0.45px rgba(0, 0, 0, 0.7);
|
-webkit-text-stroke: 0.45px rgba(0, 0, 0, 0.7);
|
||||||
paint-order: stroke fill;
|
paint-order: stroke fill;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getFrequencyRankLabelForToken,
|
getFrequencyRankLabelForToken,
|
||||||
getJlptLevelLabelForToken,
|
getJlptLevelLabelForToken,
|
||||||
normalizeSubtitle,
|
normalizeSubtitle,
|
||||||
|
prepareSecondarySubtitleLines,
|
||||||
sanitizeSubtitleHoverTokenColor,
|
sanitizeSubtitleHoverTokenColor,
|
||||||
shouldRenderTokenizedSubtitle,
|
shouldRenderTokenizedSubtitle,
|
||||||
} from './subtitle-render.js';
|
} 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,
|
/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;/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -652,6 +652,43 @@ function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void
|
|||||||
root.appendChild(fragment);
|
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<string>();
|
||||||
|
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) {
|
export function createSubtitleRenderer(ctx: RendererContext) {
|
||||||
let lastPrimarySubtitleRenderKey: string | null = null;
|
let lastPrimarySubtitleRenderKey: string | null = null;
|
||||||
let lastPrimarySubtitleNormalizedText: string | null = null;
|
let lastPrimarySubtitleNormalizedText: string | null = null;
|
||||||
@@ -750,15 +787,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
|||||||
ctx.dom.secondarySubRoot.replaceChildren();
|
ctx.dom.secondarySubRoot.replaceChildren();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
|
||||||
const normalized = text
|
const lines = prepareSecondarySubtitleLines(text);
|
||||||
.replace(/\\N/g, '\n')
|
|
||||||
.replace(/\\n/g, '\n')
|
|
||||||
.replace(/\{[^}]*\}/g, '')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if (!normalized) return;
|
|
||||||
|
|
||||||
const lines = normalized.split('\n');
|
|
||||||
for (let i = 0; i < lines.length; i += 1) {
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
if (line) {
|
if (line) {
|
||||||
|
|||||||
Reference in New Issue
Block a user