mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-24 12:15:27 -07:00
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.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [],
|
||||
}),
|
||||
'それよりも ノート…',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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, 'それよりも ノート…');
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user