mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-20 12:15:26 -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
385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
|
import {
|
|
resolveCanonicalPrimarySubtitle,
|
|
resolvePrimarySubtitleText,
|
|
stripCanonicalFragmentLines,
|
|
} from './primary-subtitle-text';
|
|
|
|
test('resolvePrimarySubtitleText collapses full-span ASS style layers through parsed cues', () => {
|
|
const ass = [
|
|
'[Events]',
|
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
|
'Dialogue: 3,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)\\bord0\\blur0.8}鏡の奥まで目を凝らして',
|
|
'Dialogue: 2,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)}鏡の奥まで目を凝らして',
|
|
'Dialogue: 1,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)\\bord6}鏡の奥まで目を凝らして',
|
|
'Dialogue: 0,0:22:40.05,0:22:45.76,EDJP,,0,0,0,,{\\fad(400,400)\\bord8\\blur4}鏡の奥まで目を凝らして',
|
|
].join('\n');
|
|
const cues = parseSubtitleCues(ass, 'polar-opposites-s01e08.ass');
|
|
|
|
assert.deepEqual(cues, [
|
|
{ startTime: 22 * 60 + 40.05, endTime: 22 * 60 + 45.76, text: '鏡の奥まで目を凝らして' },
|
|
]);
|
|
|
|
assert.equal(
|
|
resolvePrimarySubtitleText({
|
|
liveText: [
|
|
'鏡の奥まで目を凝らして',
|
|
'鏡の奥まで目を凝らして',
|
|
'鏡の奥まで目を凝らして',
|
|
'鏡の奥まで目を凝らして',
|
|
].join('\n'),
|
|
currentTimeSec: 22 * 60 + 44,
|
|
cues,
|
|
}),
|
|
'鏡の奥まで目を凝らして',
|
|
);
|
|
});
|
|
|
|
test('resolvePrimarySubtitleText keeps live text when active parsed cues do not explain it all', () => {
|
|
const liveText = '普通のセリフ\n鏡の奥まで目を凝らして\n鏡の奥まで目を凝らして';
|
|
|
|
assert.equal(
|
|
resolvePrimarySubtitleText({
|
|
liveText,
|
|
currentTimeSec: 2,
|
|
cues: [{ startTime: 1, endTime: 3, text: '鏡の奥まで目を凝らして' }],
|
|
}),
|
|
liveText,
|
|
);
|
|
});
|
|
|
|
test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () => {
|
|
assert.equal(
|
|
resolvePrimarySubtitleText({
|
|
liveText: '一行目\n一行目\n二行目\n二行目',
|
|
currentTimeSec: 2,
|
|
cues: [
|
|
{ startTime: 1, endTime: 3, text: '一行目' },
|
|
{ startTime: 1, endTime: 3, text: '二行目' },
|
|
],
|
|
}),
|
|
'一行目\n二行目',
|
|
);
|
|
});
|
|
|
|
test('resolvePrimarySubtitleText collapses whitespace variants of one ASS lyric', () => {
|
|
const ass = [
|
|
'[Events]',
|
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
|
'Dialogue: 2,0:00:01.00,0:00:03.00,EDJP,,0,0,0,,少しだけ好きになる',
|
|
'Dialogue: 1,0:00:01.00,0:00:03.00,EDJP,,0,0,0,,少しだけ\\h好きになる',
|
|
'Dialogue: 0,0:00:01.00,0:00:03.00,EDJP,,0,0,0,,少しだけ 好きになる',
|
|
].join('\n');
|
|
const cues = parseSubtitleCues(ass, 'polar-opposites-s01e10.ass');
|
|
|
|
assert.deepEqual(
|
|
cues.map((cue) => cue.text),
|
|
['少しだけ好きになる', '少しだけ 好きになる', '少しだけ 好きになる'],
|
|
);
|
|
assert.equal(
|
|
resolvePrimarySubtitleText({
|
|
liveText: ['少しだけ好きになる', '少しだけ 好きになる', '少しだけ 好きになる'].join('\n'),
|
|
currentTimeSec: 2,
|
|
cues,
|
|
}),
|
|
'少しだけ好きになる',
|
|
);
|
|
});
|
|
|
|
test('resolvePrimarySubtitleText tolerates stale time-pos at a parsed cue edge', () => {
|
|
assert.equal(
|
|
resolvePrimarySubtitleText({
|
|
liveText: '新しい行\n新しい行',
|
|
currentTimeSec: 0.8,
|
|
cues: [{ startTime: 1, endTime: 3, text: '新しい行' }],
|
|
}),
|
|
'新しい行',
|
|
);
|
|
});
|
|
|
|
test('resolvePrimarySubtitleText prefers an active canonical cue over flattened mpv glyphs', () => {
|
|
// mpv renders each simultaneously active ASS event on its own sub-text line.
|
|
const text = resolvePrimarySubtitleText({
|
|
liveText: '今\n今\n今\n手\n手\n手\nにある\nにある\nにある',
|
|
currentTimeSec: 2,
|
|
cues: [
|
|
{
|
|
startTime: 1.2,
|
|
endTime: 3.8,
|
|
text: '今 手にある',
|
|
source: 'canonical-ass',
|
|
},
|
|
],
|
|
});
|
|
|
|
assert.equal(text, '今 手にある');
|
|
});
|
|
|
|
test('resolvePrimarySubtitleText preserves live text outside canonical cue timing', () => {
|
|
const text = resolvePrimarySubtitleText({
|
|
liveText: '通常の会話',
|
|
currentTimeSec: 8,
|
|
cues: [
|
|
{
|
|
startTime: 1.2,
|
|
endTime: 3.8,
|
|
text: '今 手にある',
|
|
source: 'canonical-ass',
|
|
},
|
|
],
|
|
});
|
|
|
|
assert.equal(text, '通常の会話');
|
|
});
|
|
|
|
test('resolvePrimarySubtitleText keeps concurrent dialogue that is not part of the animation', () => {
|
|
// An insert song's canonical window can overlap real dialogue on the same track.
|
|
const text = resolvePrimarySubtitleText({
|
|
liveText: '普通のセリフ\n今\n手にある',
|
|
currentTimeSec: 2,
|
|
cues: [
|
|
{
|
|
startTime: 1.2,
|
|
endTime: 3.8,
|
|
text: '今 手にある',
|
|
source: 'canonical-ass',
|
|
},
|
|
],
|
|
});
|
|
|
|
assert.equal(text, '普通のセリフ\n今\n手にある');
|
|
});
|
|
|
|
test('resolvePrimarySubtitleText keeps a fresh line starting just after the animation ended', () => {
|
|
const text = resolvePrimarySubtitleText({
|
|
liveText: '次のセリフ',
|
|
currentTimeSec: 4.1,
|
|
cues: [
|
|
{
|
|
startTime: 1.2,
|
|
endTime: 3.8,
|
|
text: '今 手にある',
|
|
source: 'canonical-ass',
|
|
},
|
|
],
|
|
});
|
|
|
|
assert.equal(text, '次のセリフ');
|
|
});
|
|
|
|
test('resolvePrimarySubtitleText survives overlapping frames of consecutive karaoke lines', () => {
|
|
// Near a line boundary the previous line's exit frames and the next line's entrance
|
|
// frames render together; neither line alone explains every live segment.
|
|
const cues = [
|
|
{ startTime: 1.2, endTime: 3.8, text: '今 手にある', source: 'canonical-ass' as const },
|
|
{ startTime: 3.8, endTime: 6.4, text: '物差しでは', source: 'canonical-ass' as const },
|
|
];
|
|
|
|
assert.equal(
|
|
resolvePrimarySubtitleText({
|
|
liveText: '手にある\n物差し\nでは',
|
|
currentTimeSec: 3.6,
|
|
cues,
|
|
}),
|
|
'今 手にある',
|
|
);
|
|
assert.equal(
|
|
resolvePrimarySubtitleText({
|
|
liveText: '手にある\n物差し\nでは',
|
|
currentTimeSec: 3.9,
|
|
cues,
|
|
}),
|
|
'物差しでは',
|
|
);
|
|
});
|
|
|
|
test('resolvePrimarySubtitleText combines simultaneous canonical cues in source order', () => {
|
|
const text = resolvePrimarySubtitleText({
|
|
liveText: 'fir\nst\nsecond',
|
|
currentTimeSec: 2,
|
|
cues: [
|
|
{ startTime: 1, endTime: 3, text: 'first', source: 'canonical-ass' },
|
|
{ startTime: 1.5, endTime: 2.5, text: 'second', source: 'canonical-ass' },
|
|
],
|
|
});
|
|
|
|
assert.equal(text, 'first\nsecond');
|
|
});
|
|
|
|
test('resolvePrimarySubtitleText collapses whitespace variants of a canonical lyric', () => {
|
|
assert.equal(
|
|
resolvePrimarySubtitleText({
|
|
liveText: '少しだけ好きになる\n少しだけ 好きになる',
|
|
currentTimeSec: 2,
|
|
cues: [
|
|
{ startTime: 1, endTime: 3, text: '少しだけ好きになる', source: 'canonical-ass' },
|
|
{ startTime: 1, endTime: 3, text: '少しだけ 好きになる', source: 'canonical-ass' },
|
|
],
|
|
}),
|
|
'少しだけ好きになる',
|
|
);
|
|
});
|
|
|
|
test('resolveCanonicalPrimarySubtitle covers a nearby generated animation edge', () => {
|
|
const cue = {
|
|
startTime: 1.2,
|
|
endTime: 3.8,
|
|
text: '今 手にある',
|
|
source: 'canonical-ass' as const,
|
|
};
|
|
const resolved = resolveCanonicalPrimarySubtitle({
|
|
liveText: '今\n手にある',
|
|
currentTimeSec: 0.8,
|
|
cues: [cue],
|
|
});
|
|
|
|
assert.deepEqual(resolved, {
|
|
text: '今 手にある',
|
|
startTime: 1.2,
|
|
endTime: 3.8,
|
|
cues: [cue],
|
|
});
|
|
});
|
|
|
|
test('resolveCanonicalPrimarySubtitle covers exit frames that outlive the authored timing', () => {
|
|
// Real generated animations keep exit fragments on screen well past the authored
|
|
// comment window; the recorded animation envelope is what makes them resolvable.
|
|
const cue = {
|
|
startTime: 1.2,
|
|
endTime: 3.8,
|
|
text: '今 手にある',
|
|
source: 'canonical-ass' as const,
|
|
animationStartTime: 0.8,
|
|
animationEndTime: 5.6,
|
|
};
|
|
const resolved = resolveCanonicalPrimarySubtitle({
|
|
liveText: '今\n手にある',
|
|
currentTimeSec: 5.4,
|
|
cues: [cue],
|
|
});
|
|
|
|
assert.deepEqual(resolved, {
|
|
text: '今 手にある',
|
|
startTime: 1.2,
|
|
endTime: 3.8,
|
|
cues: [cue],
|
|
});
|
|
});
|
|
|
|
test('resolvePrimarySubtitleText handles late exit frames overlapping the next active line', () => {
|
|
// The previous line's exit fragments can persist more than a second into the next
|
|
// authored line. The next line supplies the text; the previous line's envelope
|
|
// explains its lingering fragments.
|
|
const cues = [
|
|
{
|
|
startTime: 1.2,
|
|
endTime: 3.8,
|
|
text: '今 手にある',
|
|
source: 'canonical-ass' as const,
|
|
animationStartTime: 0.8,
|
|
animationEndTime: 5.6,
|
|
},
|
|
{
|
|
startTime: 3.8,
|
|
endTime: 6.4,
|
|
text: '物差しでは',
|
|
source: 'canonical-ass' as const,
|
|
animationStartTime: 3.4,
|
|
animationEndTime: 7.0,
|
|
},
|
|
];
|
|
|
|
assert.equal(
|
|
resolvePrimarySubtitleText({
|
|
liveText: '手にある\n手にある\n物差し\nでは',
|
|
currentTimeSec: 5.2,
|
|
cues,
|
|
}),
|
|
'物差しでは',
|
|
);
|
|
});
|
|
|
|
test('resolveCanonicalPrimarySubtitle rejects unrelated live text at the animation edge', () => {
|
|
const resolved = resolveCanonicalPrimarySubtitle({
|
|
liveText: '次のセリフ',
|
|
currentTimeSec: 4.1,
|
|
cues: [
|
|
{
|
|
startTime: 1.2,
|
|
endTime: 3.8,
|
|
text: '今 手にある',
|
|
source: 'canonical-ass',
|
|
},
|
|
],
|
|
});
|
|
|
|
assert.equal(resolved, null);
|
|
});
|
|
|
|
test('stripCanonicalFragmentLines drops fragment lines but keeps concurrent dialogue', () => {
|
|
const cues = [
|
|
{
|
|
startTime: 1.2,
|
|
endTime: 3.8,
|
|
text: '今 手にある',
|
|
source: 'canonical-ass' as const,
|
|
},
|
|
];
|
|
|
|
assert.equal(
|
|
stripCanonicalFragmentLines({
|
|
liveText: '普通のセリフ\n今\n手にある',
|
|
currentTimeSec: 2,
|
|
cues,
|
|
}),
|
|
'普通のセリフ',
|
|
);
|
|
// No canonical cue nearby: nothing to strip.
|
|
assert.equal(
|
|
stripCanonicalFragmentLines({ liveText: '普通のセリフ\n今', currentTimeSec: 30, cues }),
|
|
'普通のセリフ\n今',
|
|
);
|
|
// Everything matched (defensive): return the input rather than empty text.
|
|
assert.equal(
|
|
stripCanonicalFragmentLines({ liveText: '今\n手にある', currentTimeSec: 2, cues }),
|
|
'今\n手にある',
|
|
);
|
|
});
|
|
|
|
test('resolveCanonicalPrimarySubtitle picks the cue its fragments spell, not the nearest', () => {
|
|
// In the gap between two authored spans, the next line sits closer in time while only
|
|
// the previous line's exit fragments are on screen: the fragments decide.
|
|
const cues = [
|
|
{
|
|
startTime: 1,
|
|
endTime: 3,
|
|
text: '今 手にある',
|
|
source: 'canonical-ass' as const,
|
|
animationStartTime: 0.6,
|
|
animationEndTime: 3.9,
|
|
},
|
|
{
|
|
startTime: 4,
|
|
endTime: 6,
|
|
text: '物差しでは',
|
|
source: 'canonical-ass' as const,
|
|
animationStartTime: 3.5,
|
|
animationEndTime: 6.4,
|
|
},
|
|
];
|
|
|
|
assert.equal(
|
|
resolveCanonicalPrimarySubtitle({ liveText: '手にある', currentTimeSec: 3.8, cues })?.text,
|
|
'今 手にある',
|
|
);
|
|
// Fragments of both lines in the gap: both envelopes cover the moment (distance 0),
|
|
// and the earlier line wins the tie while it is still animating out.
|
|
assert.equal(
|
|
resolveCanonicalPrimarySubtitle({ liveText: '手にある\n物差し', currentTimeSec: 3.8, cues })
|
|
?.text,
|
|
'今 手にある',
|
|
);
|
|
});
|