mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-24 12:15:27 -07:00
fix(subtitles): suppress overlay duplicates and reset ASS cleanup
- Suppress overlapping decorative karaoke and shadow layer copies - Clear stale ASS-only fallback sanitization after source refresh failures
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Fragmented ASS karaoke keeps spaces authored at event boundaries and recovers Latin word spaces encoded only by positioned fragment gaps, including word gaps measured across wide glyphs that width normalization alone reads as ordinary letter advances. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become concatenated primary or secondary lines. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display.
|
||||
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Fragmented ASS karaoke keeps spaces authored at event boundaries and recovers Latin word spaces encoded only by positioned fragment gaps, including word gaps measured across wide glyphs that width normalization alone reads as ordinary letter advances. Progressive karaoke highlights, offset shadow copies, and overlapping decorative glyphs remain suppressed. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become concatenated primary or secondary lines. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display. A failed source refresh also clears ASS-only cleanup so fallback text from other formats stays intact.
|
||||
|
||||
@@ -1558,17 +1558,23 @@ test('parseSubtitleCues suppresses a karaoke highlight sweep without publishing
|
||||
`Dialogue: ${layer},0:00:01.00,0:00:04.00,ED Romaji,,0,0,0,fx,{\\pos(${x},60)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`,
|
||||
),
|
||||
),
|
||||
...[40, 41].flatMap((layer) =>
|
||||
sweepFragments.map(
|
||||
([fragment, x, start, end]) =>
|
||||
`Dialogue: ${layer},${start},${end},ED Romaji2,,0,0,0,fx,{\\an5\\pos(${x},60)\\t(150,290,\\1a&HFF&)}${fragment}`,
|
||||
...sweepFragments.flatMap(([fragment, x, start, end]) =>
|
||||
[
|
||||
[40, x, 60],
|
||||
[41, x + 4, 64],
|
||||
].map(
|
||||
([layer, copyX, copyY]) =>
|
||||
`Dialogue: ${layer},${start},${end},ED Romaji2,,0,0,0,fx,{\\an5\\pos(${copyX},${copyY})\\t(150,290,\\1a&HFF&)}${fragment}`,
|
||||
),
|
||||
),
|
||||
'Dialogue: 42,0:00:01.20,0:00:01.30,ED Romaji2,,0,0,0,fx,{\\fnWebdings\\pos(900,50)\\t(0,100,\\fscx120)}a',
|
||||
'Dialogue: 42,0:00:04.00,0:00:04.20,ED Romaji2,,0,0,0,fx,{\\fnWebdings\\pos(900,50)\\t(0,100,\\fscx120)}z',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
assert.equal(cues.length, 1);
|
||||
assert.equal(cues.length, 2);
|
||||
assert.equal(cues[0]?.text.replace(/\s+/gu, ''), 'tosouomo');
|
||||
assert.equal(cues[1]?.text, 'z');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues collapses drop-shadow layer copies offset by a few pixels', () => {
|
||||
|
||||
@@ -809,15 +809,21 @@ interface FragmentInterval {
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
/** Event time ranges with layer copies (same text, placement, and timing) collapsed. */
|
||||
/** Event time ranges with repeated same-text, same-time layer copies collapsed. */
|
||||
function distinctFragmentIntervals(events: readonly AnnotatedSubtitleCue[]): FragmentInterval[] {
|
||||
const intervals: FragmentInterval[] = [];
|
||||
const seen = new Set<string>();
|
||||
const previousEvents: AnnotatedSubtitleCue[] = [];
|
||||
for (const event of events) {
|
||||
const anchors = [...fragmentPlacementAnchors(event)].sort().join('|');
|
||||
const key = `${compactCueMatchText(event)}\0${anchors}\0${event.startTime}\0${event.endTime}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const compactText = compactCueMatchText(event);
|
||||
const isLayerCopy = previousEvents.some(
|
||||
(previous) =>
|
||||
compactCueMatchText(previous) === compactText &&
|
||||
previous.startTime === event.startTime &&
|
||||
previous.endTime === event.endTime &&
|
||||
isRepeatedFragmentCopy(previous, event),
|
||||
);
|
||||
previousEvents.push(event);
|
||||
if (isLayerCopy) continue;
|
||||
intervals.push({ startTime: event.startTime, endTime: event.endTime });
|
||||
}
|
||||
return intervals.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime);
|
||||
@@ -1018,6 +1024,13 @@ function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): Annotate
|
||||
const lineEvents = decorative.size ? events.filter((event) => !decorative.has(event)) : events;
|
||||
if (isProgressiveHighlightSweepGroup(lineEvents)) {
|
||||
lineEvents.forEach((event) => suppressed.add(event));
|
||||
const spanStart = Math.min(...lineEvents.map((event) => event.startTime));
|
||||
const spanEnd = Math.max(...lineEvents.map((event) => event.endTime));
|
||||
for (const overlay of decorative) {
|
||||
if (overlay.startTime < spanEnd && overlay.endTime > spanStart) {
|
||||
suppressed.add(overlay);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const cluster of clusterAssFragmentEvents(lineEvents)) {
|
||||
|
||||
@@ -490,6 +490,41 @@ test('secondary disconnect clears stale ASS fallback sanitization state', async
|
||||
assert.deepEqual(broadcasts, ['Visible line\n\\\n{\\fr0']);
|
||||
});
|
||||
|
||||
test('secondary source refresh failure clears stale ASS fallback sanitization state', async () => {
|
||||
let resolveCalls = 0;
|
||||
const broadcasts: string[] = [];
|
||||
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 '/media/video.mkv';
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => 2,
|
||||
resolveSubtitleSource: async () => {
|
||||
resolveCalls += 1;
|
||||
if (resolveCalls === 1) {
|
||||
return { path: '/subs/english.ass', sourceKey: 'english' };
|
||||
}
|
||||
throw new Error('source refresh failed');
|
||||
},
|
||||
loadSubtitleSourceText: async () => '',
|
||||
parseSubtitleCues: () => [],
|
||||
setCurrentSecondaryText: () => {},
|
||||
broadcastSecondaryText: (text) => broadcasts.push(text),
|
||||
});
|
||||
|
||||
await controller.refresh();
|
||||
await controller.refresh();
|
||||
broadcasts.length = 0;
|
||||
controller.handleLiveText('Visible line\n\\\n{\\fr0');
|
||||
|
||||
assert.deepEqual(broadcasts, ['Visible line\n\\\n{\\fr0']);
|
||||
});
|
||||
|
||||
test('secondary track controller reuses parsed cues for an unchanged embedded track', async () => {
|
||||
let resolveCalls = 0;
|
||||
let parseCalls = 0;
|
||||
|
||||
@@ -291,6 +291,7 @@ export function createSecondarySubtitleTrackController(deps: {
|
||||
publish(resolveAtTime(deps.getCurrentTimePos()));
|
||||
} catch (error) {
|
||||
if (generation !== refreshGeneration) return;
|
||||
activeSourceUsesAssSyntax = false;
|
||||
deps.logWarn?.('[secondary-subtitle-track] failed to parse selected source', error);
|
||||
useLiveFallback();
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user