mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-20 00:15:27 -07:00
- Reconstruct fragment-only karaoke while preserving canonical animated signs - Reprocess live subtitles after initial playback and seek updates - Add a development launcher for playable media files
401 lines
13 KiB
TypeScript
401 lines
13 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
|
import {
|
|
createSecondarySubtitleTrackController,
|
|
findActiveSubtitleText,
|
|
} from './secondary-subtitle-track';
|
|
|
|
test('findActiveSubtitleText combines unique simultaneous parsed cues', () => {
|
|
assert.equal(
|
|
findActiveSubtitleText(
|
|
[
|
|
{ startTime: 1, endTime: 3, text: 'Your' },
|
|
{ startTime: 1, endTime: 3, text: 'Your' },
|
|
{ startTime: 1, endTime: 3, text: 'mosaic' },
|
|
],
|
|
2,
|
|
),
|
|
'Your\nmosaic',
|
|
);
|
|
});
|
|
|
|
test('findActiveSubtitleText collapses whitespace variants of one ASS lyric', () => {
|
|
assert.equal(
|
|
findActiveSubtitleText(
|
|
[
|
|
{ startTime: 1, endTime: 3, text: '少しだけ好きになる' },
|
|
{ startTime: 1, endTime: 3, text: '少しだけ 好きになる' },
|
|
{ startTime: 1, endTime: 3, text: '少しだけ 好きになる' },
|
|
],
|
|
2,
|
|
),
|
|
'少しだけ好きになる',
|
|
);
|
|
});
|
|
|
|
test('parsed secondary lyrics keep explicit ASS vertical order when durations alternate', () => {
|
|
const lyric = (options: { start: string; end: string; style: string; y: number; text: string }) =>
|
|
`Dialogue: 0,0:00:${options.start},0:00:${options.end},${options.style},,0,0,0,fx,{\\move(100,${options.y},120,${options.y})\\t(0,200,\\fscx110)}${options.text}\\N{\\p1}m 0 0 l 0 5`;
|
|
const ass = [
|
|
'[Events]',
|
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
|
lyric({
|
|
start: '01.00',
|
|
end: '02.20',
|
|
style: 'ed_romaji',
|
|
y: 66,
|
|
text: 'ima wo kakusarechau mae ni',
|
|
}),
|
|
lyric({
|
|
start: '01.00',
|
|
end: '02.00',
|
|
style: 'ed_english',
|
|
y: 1020,
|
|
text: 'Before the present moment gets hidden away.',
|
|
}),
|
|
lyric({
|
|
start: '03.00',
|
|
end: '04.00',
|
|
style: 'ed_romaji',
|
|
y: 66,
|
|
text: 'ame mitai ni hikatteru',
|
|
}),
|
|
lyric({
|
|
start: '03.00',
|
|
end: '04.20',
|
|
style: 'ed_english',
|
|
y: 1020,
|
|
text: 'Is shining like rain.',
|
|
}),
|
|
].join('\n');
|
|
const cues = parseSubtitleCues(ass, 'polar-opposites-s01e08.ass');
|
|
|
|
assert.equal(
|
|
findActiveSubtitleText(cues, 1.5),
|
|
'ima wo kakusarechau mae ni\nBefore the present moment gets hidden away.',
|
|
);
|
|
assert.equal(findActiveSubtitleText(cues, 3.5), 'ame mitai ni hikatteru\nIs shining like rain.');
|
|
});
|
|
|
|
test('unpositioned secondary lyrics fall back to ASS source order', () => {
|
|
const ass = [
|
|
'[Events]',
|
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
|
'Dialogue: 0,0:00:01.00,0:00:02.20,ED Romaji,,0,0,0,,ima wo kakusarechau mae ni',
|
|
'Dialogue: 0,0:00:01.00,0:00:02.00,ED English,,0,0,0,,Before the present moment gets hidden away.',
|
|
].join('\n');
|
|
|
|
assert.equal(
|
|
findActiveSubtitleText(parseSubtitleCues(ass, 'ending.ass'), 1.5),
|
|
'ima wo kakusarechau mae ni\nBefore the present moment gets hidden away.',
|
|
);
|
|
});
|
|
|
|
test('findActiveSubtitleText keeps a canonical ASS cue for its generated animation span', () => {
|
|
const poof = {
|
|
startTime: 1110.67,
|
|
endTime: 1110.71,
|
|
text: 'POOF',
|
|
source: 'canonical-ass' as const,
|
|
animationStartTime: 1110.67,
|
|
animationEndTime: 1111.59,
|
|
};
|
|
|
|
assert.equal(findActiveSubtitleText([poof], 1111.58), 'POOF');
|
|
assert.equal(findActiveSubtitleText([poof], 1111.59), '');
|
|
});
|
|
|
|
test('ASS fragment karaoke stays separated by style with authored word spacing', () => {
|
|
const lineEvents = (
|
|
style: string,
|
|
fragments: readonly string[],
|
|
y: number,
|
|
baseTime = 1,
|
|
): string[] => {
|
|
const events: string[] = [];
|
|
for (const layer of [0, 1]) {
|
|
fragments.forEach((fragment, index) => {
|
|
const x = 100 + index * 40;
|
|
const start = (baseTime + index * 0.25).toFixed(2).padStart(5, '0');
|
|
const end = (baseTime + 3 + index * 0.2).toFixed(2).padStart(5, '0');
|
|
events.push(
|
|
`Dialogue: ${layer},0:00:${start},0:00:${end},${style},,0,0,0,,{\\pos(${x},${y})\\t(0,200,\\fscx110)}${fragment}\\N{\\p1}m 0 0 l 0 10`,
|
|
);
|
|
});
|
|
}
|
|
return events;
|
|
};
|
|
const ass = [
|
|
'[Events]',
|
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
|
...lineEvents('ed_romaji', ['ji', 'gu', 'za', 'gu ', 'na', 'mi'], 70),
|
|
...lineEvents('ed_english', ['Pas', 'si', 'ng ', 'thro', 'u', 'gh '], 110),
|
|
...lineEvents('op_english', ['I', 'want', 'to', 'go'], 110, 7),
|
|
].join('\n');
|
|
|
|
assert.equal(
|
|
findActiveSubtitleText(parseSubtitleCues(ass, 'ending.ass'), 2.5),
|
|
'jiguzagu nami\nPassing through',
|
|
);
|
|
// Some generated scripts discard spaces and retain only positioned chunks. Joining
|
|
// without invented separators avoids turning one word into spaced syllables.
|
|
assert.equal(findActiveSubtitleText(parseSubtitleCues(ass, 'ending.ass'), 8.5), 'Iwanttogo');
|
|
});
|
|
|
|
test('findActiveSubtitleText keeps a complete reconstructed line over entrance fragments', () => {
|
|
const current = {
|
|
startTime: 1,
|
|
endTime: 4,
|
|
text: 'Complete current line',
|
|
source: 'reconstructed-ass' as const,
|
|
assStyle: 'op_english',
|
|
};
|
|
const nextEntrance = {
|
|
startTime: 3.8,
|
|
endTime: 4.2,
|
|
text: 'Ne',
|
|
source: 'reconstructed-ass' as const,
|
|
assStyle: 'op_english',
|
|
};
|
|
const nextLine = {
|
|
startTime: 4,
|
|
endTime: 7,
|
|
text: 'Next complete line',
|
|
source: 'reconstructed-ass' as const,
|
|
assStyle: 'op_english',
|
|
};
|
|
|
|
assert.equal(findActiveSubtitleText([current, nextEntrance], 3.9), current.text);
|
|
assert.equal(findActiveSubtitleText([current, nextEntrance, nextLine], 4.1), nextLine.text);
|
|
});
|
|
|
|
test('secondary track controller parses the selected ASS file before publishing', async () => {
|
|
const broadcasts: string[] = [];
|
|
let currentText = '';
|
|
const resolverInputs: Array<{ allowSelectedFallback?: boolean }> = [];
|
|
const ass = `[Events]
|
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
Dialogue: 0,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
|
|
Dialogue: 1,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
|
|
Dialogue: 2,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
|
|
Dialogue: 3,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
|
|
Dialogue: 4,0:00:01.00,0:00:03.00,Sign,,0,0,0,,mosaic`;
|
|
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';
|
|
if (name === 'secondary-sub-delay') return 0;
|
|
return null;
|
|
},
|
|
}),
|
|
getCurrentTimePos: () => 2,
|
|
resolveSubtitleSource: async (input) => {
|
|
resolverInputs.push(input);
|
|
return { path: '/subs/english.ass', sourceKey: '/subs/english.ass' };
|
|
},
|
|
loadSubtitleSourceText: async () => ass,
|
|
parseSubtitleCues,
|
|
setCurrentSecondaryText: (text) => {
|
|
currentText = text;
|
|
},
|
|
broadcastSecondaryText: (text) => broadcasts.push(text),
|
|
});
|
|
|
|
await controller.refresh();
|
|
controller.handleLiveText('Your\nYour\nYour\nYour\nmosaic');
|
|
|
|
assert.equal(resolverInputs[0]?.allowSelectedFallback, false);
|
|
assert.equal(currentText, 'Your\nmosaic');
|
|
assert.deepEqual(broadcasts, ['Your\nmosaic']);
|
|
});
|
|
|
|
test('secondary track controller follows parsed cue timing and subtitle delay', async () => {
|
|
const broadcasts: string[] = [];
|
|
let time = 2.25;
|
|
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';
|
|
if (name === 'secondary-sub-delay') return 0.5;
|
|
return null;
|
|
},
|
|
}),
|
|
getCurrentTimePos: () => time,
|
|
resolveSubtitleSource: async () => ({ path: '/subs/english.srt', sourceKey: 'english' }),
|
|
loadSubtitleSourceText: async () => '',
|
|
parseSubtitleCues: () => [
|
|
{ startTime: 1, endTime: 2, text: 'first' },
|
|
{ startTime: 2, endTime: 3, text: 'second' },
|
|
],
|
|
setCurrentSecondaryText: () => {},
|
|
broadcastSecondaryText: (text) => broadcasts.push(text),
|
|
});
|
|
|
|
await controller.refresh();
|
|
controller.handleDelayChange(0);
|
|
time = 3.25;
|
|
controller.handleTimePos(time);
|
|
|
|
assert.deepEqual(broadcasts, ['first', 'second', '']);
|
|
});
|
|
|
|
test('secondary track controller clears old parsed text immediately on a track change', async () => {
|
|
const broadcasts: string[] = [];
|
|
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, external: true }];
|
|
if (name === 'path') return '/media/video.mkv';
|
|
if (name === 'secondary-sub-delay') return 0;
|
|
return null;
|
|
},
|
|
}),
|
|
getCurrentTimePos: () => 2,
|
|
resolveSubtitleSource: async () => ({ path: '/subs/old.ass', sourceKey: 'old' }),
|
|
loadSubtitleSourceText: async () => '',
|
|
parseSubtitleCues: () => [{ startTime: 1, endTime: 3, text: 'old parsed text' }],
|
|
setCurrentSecondaryText: (text) => {
|
|
currentText = text;
|
|
},
|
|
broadcastSecondaryText: (text) => broadcasts.push(text),
|
|
});
|
|
|
|
await controller.refresh();
|
|
controller.handleTrackChange();
|
|
controller.handleLiveText('new live text');
|
|
|
|
assert.equal(currentText, 'new live text');
|
|
assert.deepEqual(broadcasts, ['old parsed text', '', 'new live text']);
|
|
});
|
|
|
|
test('secondary track controller falls back to live mpv text without a readable source', async () => {
|
|
const broadcasts: string[] = [];
|
|
let currentText = '';
|
|
const controller = createSecondarySubtitleTrackController({
|
|
getMpvClient: () => ({
|
|
connected: true,
|
|
requestProperty: async (name) => {
|
|
if (name === 'secondary-sid') return 'no';
|
|
if (name === 'path') return '/media/video.mkv';
|
|
return null;
|
|
},
|
|
}),
|
|
getCurrentTimePos: () => 2,
|
|
resolveSubtitleSource: async () => null,
|
|
loadSubtitleSourceText: async () => '',
|
|
parseSubtitleCues: () => [],
|
|
setCurrentSecondaryText: (text) => {
|
|
currentText = text;
|
|
},
|
|
broadcastSecondaryText: (text) => broadcasts.push(text),
|
|
});
|
|
|
|
controller.handleLiveText('live fallback');
|
|
await controller.refresh();
|
|
|
|
assert.equal(currentText, 'live fallback');
|
|
assert.deepEqual(broadcasts, ['live fallback']);
|
|
});
|
|
|
|
test('secondary track controller reuses parsed cues for an unchanged embedded track', async () => {
|
|
let resolveCalls = 0;
|
|
let parseCalls = 0;
|
|
const controller = createSecondarySubtitleTrackController({
|
|
getMpvClient: () => ({
|
|
connected: true,
|
|
requestProperty: async (name) => {
|
|
if (name === 'secondary-sid') return 2;
|
|
if (name === 'track-list') {
|
|
return [{ type: 'sub', id: 2, external: false, 'ff-index': 3 }];
|
|
}
|
|
if (name === 'path') return '/media/video.mkv';
|
|
if (name === 'secondary-sub-delay') return 0;
|
|
return null;
|
|
},
|
|
}),
|
|
getCurrentTimePos: () => 2,
|
|
resolveSubtitleSource: async () => {
|
|
resolveCalls += 1;
|
|
return { path: `/tmp/extracted-${resolveCalls}.ass`, sourceKey: 'embedded-track-2' };
|
|
},
|
|
loadSubtitleSourceText: async () => '',
|
|
parseSubtitleCues: () => {
|
|
parseCalls += 1;
|
|
return [{ startTime: 1, endTime: 3, text: 'parsed' }];
|
|
},
|
|
setCurrentSecondaryText: () => {},
|
|
broadcastSecondaryText: () => {},
|
|
});
|
|
|
|
await controller.refresh();
|
|
await controller.refresh();
|
|
|
|
assert.equal(resolveCalls, 1);
|
|
assert.equal(parseCalls, 1);
|
|
});
|
|
|
|
test('secondary track controller ignores and cleans up a refresh invalidated by reset', async () => {
|
|
const broadcasts: string[] = [];
|
|
let notifyResolveStarted: (() => void) | undefined;
|
|
let releaseResolve: (() => void) | undefined;
|
|
let cleanupCalls = 0;
|
|
let parseCalls = 0;
|
|
const resolveStarted = new Promise<void>((resolve) => {
|
|
notifyResolveStarted = resolve;
|
|
});
|
|
const resolveGate = new Promise<void>((resolve) => {
|
|
releaseResolve = resolve;
|
|
});
|
|
const controller = createSecondarySubtitleTrackController({
|
|
getMpvClient: () => ({
|
|
connected: true,
|
|
requestProperty: async (name) => {
|
|
if (name === 'secondary-sid') return 2;
|
|
if (name === 'track-list') return [{ type: 'sub', id: 2, external: true }];
|
|
if (name === 'path') return '/media/video.mkv';
|
|
if (name === 'secondary-sub-delay') return 0;
|
|
return null;
|
|
},
|
|
}),
|
|
getCurrentTimePos: () => 2,
|
|
resolveSubtitleSource: async () => {
|
|
notifyResolveStarted?.();
|
|
await resolveGate;
|
|
return {
|
|
path: '/subs/secondary.ass',
|
|
sourceKey: 'secondary',
|
|
cleanup: async () => {
|
|
cleanupCalls += 1;
|
|
},
|
|
};
|
|
},
|
|
loadSubtitleSourceText: async () => '',
|
|
parseSubtitleCues: () => {
|
|
parseCalls += 1;
|
|
return [{ startTime: 1, endTime: 3, text: 'stale' }];
|
|
},
|
|
setCurrentSecondaryText: () => {},
|
|
broadcastSecondaryText: (text) => broadcasts.push(text),
|
|
});
|
|
|
|
const refresh = controller.refresh();
|
|
await resolveStarted;
|
|
controller.reset();
|
|
releaseResolve?.();
|
|
await refresh;
|
|
|
|
assert.deepEqual(broadcasts, ['']);
|
|
assert.equal(parseCalls, 0);
|
|
assert.equal(cleanupCalls, 1);
|
|
});
|