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
|
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.
|
- 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,
|
- `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
|
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.
|
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
|
- 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
|
flattened-line identity also removes long dialogue/sign repetitions that differ only in
|
||||||
whitespace or terminal punctuation, while distinct simultaneous short lines remain separate.
|
whitespace or terminal punctuation, while distinct simultaneous short lines remain separate.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
isAssTemporalCommand,
|
isAssTemporalCommand,
|
||||||
normalizePlainSubtitleText,
|
normalizePlainSubtitleText,
|
||||||
parseAssEffectField,
|
parseAssEffectField,
|
||||||
|
removeLiveGlyphFragmentLines,
|
||||||
removeAssControlDebrisLines,
|
removeAssControlDebrisLines,
|
||||||
} from './ass-text';
|
} from './ass-text';
|
||||||
|
|
||||||
@@ -202,3 +203,18 @@ test('isAnimatedAssEffectKind covers the stock animated effects only', () => {
|
|||||||
assert.equal(isAnimatedAssEffectKind('other'), false);
|
assert.equal(isAnimatedAssEffectKind('other'), false);
|
||||||
assert.equal(isAnimatedAssEffectKind('none'), 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');
|
.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 {
|
export interface NormalizePlainSubtitleTextOptions {
|
||||||
/** Fold every line break into a single space. */
|
/** Fold every line break into a single space. */
|
||||||
collapseLineBreaks?: boolean;
|
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 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
|
// 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
|
// 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;
|
cues: readonly SubtitleCue[] | null | undefined;
|
||||||
}): string {
|
}): string {
|
||||||
if (!Number.isFinite(options.currentTimeSec)) {
|
if (!Number.isFinite(options.currentTimeSec)) {
|
||||||
return options.liveText;
|
return removeLiveGlyphFragmentLines(options.liveText);
|
||||||
}
|
}
|
||||||
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec, true);
|
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec, true);
|
||||||
if (nearby.length === 0) {
|
if (nearby.length === 0) {
|
||||||
return options.liveText;
|
return removeLiveGlyphFragmentLines(options.liveText);
|
||||||
}
|
}
|
||||||
const compactCues = nearby.map((cue) => compactWhitespace(cue.text));
|
const compactCues = nearby.map((cue) => compactWhitespace(cue.text));
|
||||||
const kept = options.liveText.split('\n').filter((line) => {
|
const kept = options.liveText.split('\n').filter((line) => {
|
||||||
const compact = compactWhitespace(line);
|
const compact = compactWhitespace(line);
|
||||||
return compact && !compactCues.some((cueText) => cueText.includes(compact));
|
return compact && !compactCues.some((cueText) => cueText.includes(compact));
|
||||||
});
|
});
|
||||||
if (kept.length > 0) return kept.join('\n');
|
if (kept.length > 0) return removeLiveGlyphFragmentLines(kept.join('\n'));
|
||||||
return nearby.some((cue) => cue.assLayout?.kind === 'fragment-grid') ? '' : options.liveText;
|
if (nearby.some((cue) => cue.assLayout?.kind === 'fragment-grid')) return '';
|
||||||
|
return removeLiveGlyphFragmentLines(options.liveText);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePrimarySubtitleText(options: {
|
export function resolvePrimarySubtitleText(options: {
|
||||||
@@ -257,6 +261,6 @@ export function resolvePrimarySubtitleText(options: {
|
|||||||
cues: options.cues,
|
cues: options.cues,
|
||||||
})?.text ??
|
})?.text ??
|
||||||
resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.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(parseCalls, 0);
|
||||||
assert.equal(cleanupCalls, 1);
|
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 type { SubtitleCue } from '../../types/subtitle';
|
||||||
import { flattenedSecondarySubtitleLineIdentity } from '../../core/services/secondary-subtitle-line-identity';
|
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 = {
|
type SecondarySubtitleMpvClient = {
|
||||||
connected?: boolean;
|
connected?: boolean;
|
||||||
@@ -320,7 +323,9 @@ export function createSecondarySubtitleTrackController(deps: {
|
|||||||
refresh,
|
refresh,
|
||||||
scheduleRefresh,
|
scheduleRefresh,
|
||||||
handleLiveText(text: string): void {
|
handleLiveText(text: string): void {
|
||||||
lastLiveText = activeSourceUsesAssSyntax ? removeAssControlDebrisLines(text) : text;
|
lastLiveText = removeLiveGlyphFragmentLines(
|
||||||
|
activeSourceUsesAssSyntax ? removeAssControlDebrisLines(text) : text,
|
||||||
|
);
|
||||||
publish(resolveAtTime(deps.getCurrentTimePos()));
|
publish(resolveAtTime(deps.getCurrentTimePos()));
|
||||||
},
|
},
|
||||||
handleTimePos(timeSeconds: number): void {
|
handleTimePos(timeSeconds: number): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user