mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-19 12:18:30 -07:00
fix(subtitles): recover canonical lines from ASS animation
- Recover authored ASS lyrics and signs across overlay, sidebar, immersion, and mining - Collapse repeated karaoke phases while preserving ordinary dialogue and positioned signs
This commit is contained in:
@@ -125,6 +125,49 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async ()
|
||||
]);
|
||||
});
|
||||
|
||||
test('mineSentenceCard prefers a canonical primary subtitle snapshot', async () => {
|
||||
const created: Array<{
|
||||
sentence: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
secondarySub?: string;
|
||||
}> = [];
|
||||
|
||||
await mineSentenceCard({
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async (sentence, startTime, endTime, secondarySub) => {
|
||||
created.push({ sentence, startTime, endTime, secondarySub });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
mpvClient: {
|
||||
connected: true,
|
||||
currentSubText: '今今今手手手',
|
||||
currentSubStart: 11.4,
|
||||
currentSubEnd: 11.8,
|
||||
currentSecondarySubText: 'English subtitle',
|
||||
},
|
||||
primarySubtitle: {
|
||||
text: '今 手にある物差しでは',
|
||||
startTime: 11.13,
|
||||
endTime: 13.83,
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(created, [
|
||||
{
|
||||
sentence: '今 手にある物差しでは',
|
||||
startTime: 11.13,
|
||||
endTime: 13.83,
|
||||
secondarySub: 'English subtitle',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('mineSentenceCard refreshes secondary subtitle text before creating card', async () => {
|
||||
const created: Array<{ sentence: string; secondarySub?: string }> = [];
|
||||
const requestedProperties: string[] = [];
|
||||
|
||||
@@ -131,8 +131,8 @@ function normalizeSecondarySubText(text: unknown, primaryText: string): string |
|
||||
|
||||
async function getCurrentSecondarySubTextForSentenceCard(
|
||||
mpvClient: MpvClientLike,
|
||||
primaryText: string,
|
||||
): Promise<string | undefined> {
|
||||
const primaryText = mpvClient.currentSubText;
|
||||
if (mpvClient.requestProperty) {
|
||||
try {
|
||||
const latestSecondaryText = await mpvClient.requestProperty('secondary-sub-text');
|
||||
@@ -175,6 +175,7 @@ export async function markLastCardAsAudioCard(deps: {
|
||||
export async function mineSentenceCard(deps: {
|
||||
ankiIntegration: AnkiIntegrationLike | null;
|
||||
mpvClient: MpvClientLike | null;
|
||||
primarySubtitle?: Pick<SubtitleMiningContext, 'text' | 'startTime' | 'endTime'>;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}): Promise<boolean> {
|
||||
const anki = requireAnkiIntegration(deps.ankiIntegration, deps.showMpvOsd);
|
||||
@@ -185,16 +186,17 @@ export async function mineSentenceCard(deps: {
|
||||
deps.showMpvOsd('MPV not connected');
|
||||
return false;
|
||||
}
|
||||
if (!mpvClient.currentSubText) {
|
||||
const primaryText = deps.primarySubtitle?.text ?? mpvClient.currentSubText;
|
||||
if (!primaryText) {
|
||||
deps.showMpvOsd('No current subtitle');
|
||||
return false;
|
||||
}
|
||||
|
||||
const secondarySubText = await getCurrentSecondarySubTextForSentenceCard(mpvClient);
|
||||
const secondarySubText = await getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText);
|
||||
return await anki.createSentenceCard(
|
||||
mpvClient.currentSubText,
|
||||
mpvClient.currentSubStart,
|
||||
mpvClient.currentSubEnd,
|
||||
primaryText,
|
||||
deps.primarySubtitle?.startTime ?? mpvClient.currentSubStart,
|
||||
deps.primarySubtitle?.endTime ?? mpvClient.currentSubEnd,
|
||||
secondarySubText,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,17 +27,188 @@ function cueKey(cue: SubtitleCue): string {
|
||||
|
||||
/**
|
||||
* Identical text over an identical span is redundant however it was authored -- most
|
||||
* often a layered ASS event stacking a shadow copy under the visible one.
|
||||
* often a layered ASS event stacking a shadow copy under the visible one. When one of
|
||||
* the duplicates is a recovered canonical cue, that copy survives: dropping it would
|
||||
* strip the `source` marker and animation envelope the live overlay substitutes on.
|
||||
*/
|
||||
function collapseExactDuplicates(cues: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
|
||||
const seen = new Set<string>();
|
||||
return cues.filter((cue) => {
|
||||
const survivorByKey = new Map<string, AnnotatedSubtitleCue>();
|
||||
const keysInOrder: string[] = [];
|
||||
for (const cue of cues) {
|
||||
const key = cueKey(cue);
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
const existing = survivorByKey.get(key);
|
||||
if (!existing) {
|
||||
survivorByKey.set(key, cue);
|
||||
keysInOrder.push(key);
|
||||
} else if (!existing.source && cue.source) {
|
||||
survivorByKey.set(key, cue);
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
return keysInOrder.map((key) => survivorByKey.get(key)!);
|
||||
}
|
||||
|
||||
const SPATIAL_ASS_OVERRIDE_COMMANDS = new Set([
|
||||
'a',
|
||||
'an',
|
||||
'clip',
|
||||
'iclip',
|
||||
'move',
|
||||
'org',
|
||||
'pbo',
|
||||
'pos',
|
||||
'q',
|
||||
]);
|
||||
|
||||
interface RepeatedPhaseRun {
|
||||
cues: AnnotatedSubtitleCue[];
|
||||
indices: number[];
|
||||
}
|
||||
|
||||
// A changing override signature alone is weak: two ordinary repeats restyled with
|
||||
// different colors look identical to a phase pair. Real phase redraws carry a styling
|
||||
// stack over a full lyric line, and they exist to move a color/highlight boundary
|
||||
// *within* the line -- so every event also has an override block after visible text
|
||||
// began. An ordinary restyled repeat carries only a leading block and stays separate.
|
||||
const MIN_PHASE_EVIDENCE_OVERRIDES = 2;
|
||||
const MIN_PHASE_TEXT_LENGTH = 4;
|
||||
|
||||
function hasMidLineOverrideBlock(rawText: string): boolean {
|
||||
let sawVisibleText = false;
|
||||
for (let i = 0; i < rawText.length; i += 1) {
|
||||
if (rawText[i] === '{') {
|
||||
const close = rawText.indexOf('}', i);
|
||||
if (close === -1) {
|
||||
// Unclosed brace renders as literal text; nothing after it is markup.
|
||||
return false;
|
||||
}
|
||||
if (sawVisibleText) {
|
||||
return true;
|
||||
}
|
||||
i = close;
|
||||
} else if (!/\s/.test(rawText[i]!)) {
|
||||
sawVisibleText = true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function assStyleKey(cue: AnnotatedSubtitleCue): string {
|
||||
return `${cue.style}\0${cue.name}\0${cue.layer}`;
|
||||
}
|
||||
|
||||
function spatialOverrideSignature(cue: AnnotatedSubtitleCue): string {
|
||||
return cue.overrides
|
||||
.filter((command) => SPATIAL_ASS_OVERRIDE_COMMANDS.has(command.name.toLowerCase()))
|
||||
.map((command) => `${command.name.toLowerCase()}(${command.args})`)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function hasStableSpatialOverrides(run: readonly AnnotatedSubtitleCue[]): boolean {
|
||||
const firstSignature = spatialOverrideSignature(run[0]!);
|
||||
return run.every((cue) => spatialOverrideSignature(cue) === firstSignature);
|
||||
}
|
||||
|
||||
function hasDirectPhaseEvidence(run: readonly AnnotatedSubtitleCue[]): boolean {
|
||||
// Phases redraw one authored line in place. Whatever the animation evidence, a run
|
||||
// whose spatial placement changes is separate authored occurrences -- two flush
|
||||
// same-text `\move` signs at different coordinates must never merge.
|
||||
if (!hasStableSpatialOverrides(run)) {
|
||||
return false;
|
||||
}
|
||||
if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) {
|
||||
return true;
|
||||
}
|
||||
if (run.every((cue) => isAnimatedAssEffectKind(cue.effectKind))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [first] = run;
|
||||
return (
|
||||
first!.text.replace(/\s+/gu, '').length >= MIN_PHASE_TEXT_LENGTH &&
|
||||
run.every(
|
||||
(cue) =>
|
||||
cue.overrides.length >= MIN_PHASE_EVIDENCE_OVERRIDES &&
|
||||
hasMidLineOverrideBlock(cue.rawText),
|
||||
) &&
|
||||
run.some((cue) => cue.overrideSignature !== first!.overrideSignature)
|
||||
);
|
||||
}
|
||||
|
||||
function collectRepeatedPhaseRuns(cues: AnnotatedSubtitleCue[]): RepeatedPhaseRun[] {
|
||||
const runs: RepeatedPhaseRun[] = [];
|
||||
let start = 0;
|
||||
|
||||
while (start < cues.length) {
|
||||
const first = cues[start]!;
|
||||
const styleKey = assStyleKey(first);
|
||||
let end = start;
|
||||
|
||||
while (end + 1 < cues.length) {
|
||||
const current = cues[end]!;
|
||||
const next = cues[end + 1]!;
|
||||
const isFlush =
|
||||
Math.abs(next.startTime - current.endTime) <= DUPLICATE_CUE_GAP_TOLERANCE_SECONDS;
|
||||
if (
|
||||
first.source === 'canonical-ass' ||
|
||||
next.source === 'canonical-ass' ||
|
||||
next.text !== first.text ||
|
||||
assStyleKey(next) !== styleKey ||
|
||||
!isFlush
|
||||
) {
|
||||
break;
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
|
||||
if (end > start) {
|
||||
const indices = Array.from({ length: end - start + 1 }, (_, offset) => start + offset);
|
||||
runs.push({
|
||||
cues: indices.map((index) => cues[index]!),
|
||||
indices,
|
||||
});
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Some karaoke scripts redraw one complete lyric for each color/highlight phase. These
|
||||
* events last far longer than animation frames, but are still one sidebar/history line.
|
||||
* The events must prove themselves through direct animation metadata or changing
|
||||
* non-spatial overrides. Plain repeated dialogue and separately positioned signs stay
|
||||
* intact.
|
||||
*/
|
||||
function collapseAnimatedStylePhases(cues: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
|
||||
const runs = collectRepeatedPhaseRuns(cues);
|
||||
if (runs.length === 0) {
|
||||
return cues;
|
||||
}
|
||||
|
||||
const dropped = new Set<number>();
|
||||
const extendedEnd = new Map<number, number>();
|
||||
for (const run of runs) {
|
||||
if (!hasDirectPhaseEvidence(run.cues)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [firstIndex, ...remainingIndices] = run.indices;
|
||||
for (const index of remainingIndices) {
|
||||
dropped.add(index);
|
||||
}
|
||||
extendedEnd.set(firstIndex!, Math.max(...run.cues.map((cue) => cue.endTime)));
|
||||
}
|
||||
|
||||
if (dropped.size === 0) {
|
||||
return cues;
|
||||
}
|
||||
return cues.flatMap((cue, index) => {
|
||||
if (dropped.has(index)) {
|
||||
return [];
|
||||
}
|
||||
const endTime = extendedEnd.get(index);
|
||||
return endTime !== undefined ? [{ ...cue, endTime }] : [cue];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -176,5 +347,8 @@ export function mergeDuplicateCues(
|
||||
cues: AnnotatedSubtitleCue[],
|
||||
format: SubtitleSourceFormat,
|
||||
): AnnotatedSubtitleCue[] {
|
||||
return collapseAnimationBursts(collapseExactDuplicates(cues), format);
|
||||
const exactDeduplicated = collapseExactDuplicates(cues);
|
||||
const phaseDeduplicated =
|
||||
format === 'ass' ? collapseAnimatedStylePhases(exactDeduplicated) : exactDeduplicated;
|
||||
return collapseAnimationBursts(phaseDeduplicated, format);
|
||||
}
|
||||
|
||||
@@ -327,6 +327,122 @@ test('parseSubtitleCues collapses per-frame karaoke duplicates into one cue', ()
|
||||
assert.equal(cues[0]!.text, '過ぎ去ってしまう瞬間を');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues collapses long full-line color phases', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 1,0:03:49.75,0:03:51.21,OPJP,,0,0,0,,{\\blur0.6\\c&H312D38&\\4c&HFFFFFF&}ちゃんと目を{\\4c&HD590FF&}合わせてよ',
|
||||
'Dialogue: 1,0:03:51.21,0:03:52.25,OPJP,,0,0,0,,{\\blur0.6\\4c&H312D38&\\c&HFFFFFF&}ちゃんと目を{\\4c&HD590FF&}合わせてよ',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{
|
||||
startTime: 229.75,
|
||||
endTime: 232.25,
|
||||
text: 'ちゃんと目を合わせてよ',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps ordinary repeated dialogue separate', () => {
|
||||
// A single restyle tag on a repeated line is how ordinary dialogue gets decorated;
|
||||
// it is not phase evidence, whatever the line length.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 1,0:00:01.00,0:00:02.00,OPJP,,0,0,0,,{\\c&H111111&}歌詞',
|
||||
'Dialogue: 1,0:00:02.00,0:00:03.00,OPJP,,0,0,0,,{\\c&H222222&}歌詞',
|
||||
'Dialogue: 1,0:00:04.00,0:00:05.00,OPJP,,0,0,0,,{\\c&H333333&}別の歌詞',
|
||||
'Dialogue: 1,0:00:05.00,0:00:06.00,OPJP,,0,0,0,,{\\c&H444444&}別の歌詞',
|
||||
'Dialogue: 8,0:00:07.00,0:00:08.00,Text - JP,,0,0,0,,えっ?',
|
||||
'Dialogue: 8,0:00:08.00,0:00:09.00,Text - JP,,0,0,0,,えっ?',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{ startTime: 1, endTime: 2, text: '歌詞' },
|
||||
{ startTime: 2, endTime: 3, text: '歌詞' },
|
||||
{ startTime: 4, endTime: 5, text: '別の歌詞' },
|
||||
{ startTime: 5, endTime: 6, text: '別の歌詞' },
|
||||
{ startTime: 7, endTime: 8, text: 'えっ?' },
|
||||
{ startTime: 8, endTime: 9, text: 'えっ?' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps separately positioned temporal signs separate', () => {
|
||||
// Two flush signs with the same text but different \move paths are separate authored
|
||||
// occurrences, not phases of one redraw: temporal evidence alone must not merge them.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:02.00,Sign,,0,0,0,,{\\move(100,100,200,100)}立入禁止',
|
||||
'Dialogue: 0,0:00:02.00,0:00:03.00,Sign,,0,0,0,,{\\move(500,400,600,400)}立入禁止',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{ startTime: 1, endTime: 2, text: '立入禁止' },
|
||||
{ startTime: 2, endTime: 3, text: '立入禁止' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps richly styled ordinary repeats separate', () => {
|
||||
// Blur plus a changing color is still an ordinary restyle. Phase redraws are
|
||||
// recognized by the color/highlight boundary moving *within* the line, which these
|
||||
// leading-block-only events do not have.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:02.00,Dial,,0,0,0,,{\\blur0.4\\c&H111111&}待ってよ',
|
||||
'Dialogue: 0,0:00:02.00,0:00:03.00,Dial,,0,0,0,,{\\blur0.4\\c&H222222&}待ってよ',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{ startTime: 1, endTime: 2, text: '待ってよ' },
|
||||
{ startTime: 2, endTime: 3, text: '待ってよ' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps canonical metadata when an identical plain cue exists', () => {
|
||||
// A plain dialogue line can share exact timing and text with a recovered canonical
|
||||
// cue from another style. The canonical copy must win the exact-duplicate collapse,
|
||||
// or the live overlay loses the marker it substitutes on.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:05.00,0:00:08.00,Plain,,0,0,0,,ライン',
|
||||
'Comment: 0,0:00:05.00,0:00:08.00,OP,,0,0,0,,ライン',
|
||||
'Dialogue: 0,0:00:05.00,0:00:05.04,OP,,0,0,0,,{\\pos(1,1)\\clip(m 1 1)}ライン',
|
||||
'Dialogue: 0,0:00:05.04,0:00:05.08,OP,,0,0,0,,{\\pos(1,1)\\clip(m 2 2)}ライン',
|
||||
'Dialogue: 0,0:00:05.08,0:00:08.00,OP,,0,0,0,,{\\pos(1,1)\\clip(m 3 3)}ライン',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{
|
||||
startTime: 5,
|
||||
endTime: 8,
|
||||
text: 'ライン',
|
||||
source: 'canonical-ass',
|
||||
animationStartTime: 5,
|
||||
animationEndTime: 8,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps short styled repeats separate even with richer styling', () => {
|
||||
// Two ordinary えっ lines restyled with different colors are two utterances, not two
|
||||
// phases of one lyric: short text never satisfies the changing-override evidence path.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:02.00,Dial,,0,0,0,,{\\blur0.4\\c&H111111&}えっ',
|
||||
'Dialogue: 0,0:00:02.00,0:00:03.00,Dial,,0,0,0,,{\\blur0.4\\c&H222222&}えっ',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{ startTime: 1, endTime: 2, text: 'えっ' },
|
||||
{ startTime: 2, endTime: 3, text: 'えっ' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps back-to-back plain dialogue repeats separate', () => {
|
||||
// Several characters greeting in turn: distinct utterances that happen to abut.
|
||||
const content = [
|
||||
@@ -357,6 +473,194 @@ test('parseSubtitleCues collapses exact duplicate cues even without effect tags'
|
||||
assert.equal(cues.length, 1);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues replaces generated glyph animation with its timed canonical comment', () => {
|
||||
// Aegisub automation commonly keeps the authored lyric as a Comment and emits
|
||||
// multiple moving Dialogue layers for every glyph. This mirrors the MyGO ED script:
|
||||
// three entrance copies followed by three exit copies for each character.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Comment: 0,0:00:01.20,0:00:03.80,ED_JP,,0,0,0,,{\\fad(480,480)}今 手にある',
|
||||
'Dialogue: 0,0:00:00.80,0:00:01.50,ED_JP,,0,0,0,,{\\move(10,20,100,200)\\t(0,600,\\fscx100)}今',
|
||||
'Dialogue: 0,0:00:00.80,0:00:01.50,ED_JP,,0,0,0,,{\\move(30,40,100,200)\\t(0,600,\\fscx100)}今',
|
||||
'Dialogue: 0,0:00:00.80,0:00:01.50,ED_JP,,0,0,0,,{\\move(50,60,100,200)\\t(0,600,\\fscx100)}今',
|
||||
'Dialogue: 1,0:00:01.40,0:00:04.20,ED_JP,,0,0,0,,{\\move(100,200,20,30)\\t(2000,2600,\\blur20)}今',
|
||||
'Dialogue: 1,0:00:01.40,0:00:04.20,ED_JP,,0,0,0,,{\\move(100,200,40,50)\\t(2000,2600,\\blur20)}今',
|
||||
'Dialogue: 1,0:00:01.40,0:00:04.20,ED_JP,,0,0,0,,{\\move(100,200,60,70)\\t(2000,2600,\\blur20)}今',
|
||||
'Dialogue: 0,0:00:00.86,0:00:01.56,ED_JP,,0,0,0,,{\\move(10,20,140,200)\\t(0,600,\\fscx100)}手',
|
||||
'Dialogue: 0,0:00:00.86,0:00:01.56,ED_JP,,0,0,0,,{\\move(30,40,140,200)\\t(0,600,\\fscx100)}手',
|
||||
'Dialogue: 0,0:00:00.86,0:00:01.56,ED_JP,,0,0,0,,{\\move(50,60,140,200)\\t(0,600,\\fscx100)}手',
|
||||
'Dialogue: 1,0:00:01.46,0:00:04.26,ED_JP,,0,0,0,,{\\move(140,200,20,30)\\t(2000,2600,\\blur20)}手',
|
||||
'Dialogue: 1,0:00:01.46,0:00:04.26,ED_JP,,0,0,0,,{\\move(140,200,40,50)\\t(2000,2600,\\blur20)}手',
|
||||
'Dialogue: 1,0:00:01.46,0:00:04.26,ED_JP,,0,0,0,,{\\move(140,200,60,70)\\t(2000,2600,\\blur20)}手',
|
||||
'Dialogue: 0,0:00:00.92,0:00:01.62,ED_JP,,0,0,0,,{\\move(10,20,180,200)\\t(0,600,\\fscx100)}にある',
|
||||
'Dialogue: 0,0:00:00.92,0:00:01.62,ED_JP,,0,0,0,,{\\move(30,40,180,200)\\t(0,600,\\fscx100)}にある',
|
||||
'Dialogue: 0,0:00:00.92,0:00:01.62,ED_JP,,0,0,0,,{\\move(50,60,180,200)\\t(0,600,\\fscx100)}にある',
|
||||
'Dialogue: 1,0:00:01.52,0:00:04.32,ED_JP,,0,0,0,,{\\move(180,200,20,30)\\t(2000,2600,\\blur20)}にある',
|
||||
'Dialogue: 1,0:00:01.52,0:00:04.32,ED_JP,,0,0,0,,{\\move(180,200,40,50)\\t(2000,2600,\\blur20)}にある',
|
||||
'Dialogue: 1,0:00:01.52,0:00:04.32,ED_JP,,0,0,0,,{\\move(180,200,60,70)\\t(2000,2600,\\blur20)}にある',
|
||||
'Dialogue: 0,0:00:06.00,0:00:08.00,Dial_JP,,0,0,0,,普通の会話',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.deepEqual(cues, [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass',
|
||||
// Entrance frames start before and exit frames end after the authored timing.
|
||||
animationStartTime: 0.8,
|
||||
animationEndTime: 4.32,
|
||||
},
|
||||
{ startTime: 6, endTime: 8, text: '普通の会話' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues recovers a full Dialogue line surrounding generated fragments', () => {
|
||||
// Some scripts do not retain the authored line as a Comment. Instead, brief entrance
|
||||
// and exit events contain the complete line around a long run of generated syllables.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 1,0:00:01.00,0:00:01.15,ED Romaji,,0,0,0,fx,{\\move(100,40,60,40)}toki yo ugokidase',
|
||||
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(0,300,\\c&HFFFFFF&)}to',
|
||||
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(300,500,\\c&HFFFFFF&)}ki',
|
||||
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(500,700,\\c&HFFFFFF&)}yo',
|
||||
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(700,900,\\c&HFFFFFF&)}u',
|
||||
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(900,1100,\\c&HFFFFFF&)}go',
|
||||
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(1100,1300,\\c&HFFFFFF&)}ki',
|
||||
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(1300,1500,\\c&HFFFFFF&)}da',
|
||||
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(1500,1800,\\c&HFFFFFF&)}se',
|
||||
'Dialogue: 1,0:00:03.00,0:00:03.15,ED Romaji,,0,0,0,fx,{\\move(60,40,20,40)}toki yo ugokidase',
|
||||
'Dialogue: 0,0:00:06.00,0:00:08.00,Default,,0,0,0,,Ordinary dialogue',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{
|
||||
startTime: 1,
|
||||
endTime: 3.15,
|
||||
text: 'toki yo ugokidase',
|
||||
source: 'canonical-ass',
|
||||
animationStartTime: 1,
|
||||
animationEndTime: 3.15,
|
||||
},
|
||||
{ startTime: 6, endTime: 8, text: 'Ordinary dialogue' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues does not promote a short animated fragment as a complete line', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 1,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}my',
|
||||
'Dialogue: 2,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}my',
|
||||
'Dialogue: 1,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}m',
|
||||
'Dialogue: 2,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}m',
|
||||
'Dialogue: 1,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(120,100)\\t(20,120,\\fscx120)}y',
|
||||
'Dialogue: 2,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(120,100)\\t(20,120,\\fscx120)}y',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(
|
||||
cues.some((cue) => cue.source === 'canonical-ass'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues ignores timed comments without a matching animated dialogue cluster', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Comment: 0,0:00:01.00,0:00:03.00,Dial_JP,,0,0,0,,編集メモ',
|
||||
'Comment: 0,0:00:04.00,0:00:06.00,Dial_JP,,0,0,0,,別案の字幕',
|
||||
'Dialogue: 0,0:00:01.00,0:00:03.00,Dial_JP,,0,0,0,,通常の字幕',
|
||||
'Dialogue: 0,0:00:04.00,0:00:06.00,Dial_JP,,0,0,0,,別案の字幕',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.deepEqual(cues, [
|
||||
{ startTime: 1, endTime: 3, text: '通常の字幕' },
|
||||
{ startTime: 4, endTime: 6, text: '別案の字幕' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseAssCues returns recovered canonical cues in chronological order', () => {
|
||||
// Recovery appends recovered cues after surviving dialogue; the bare parseAssCues
|
||||
// export must still come back time-ordered.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:06.00,0:00:08.00,Dial,,0,0,0,,あとのセリフ',
|
||||
'Comment: 0,0:00:01.20,0:00:03.80,OP,,0,0,0,,雨が上がっても',
|
||||
'Dialogue: 0,0:00:01.20,0:00:01.24,OP,,0,0,0,,{\\pos(1,1)\\clip(m 1 1)}雨が上がっても',
|
||||
'Dialogue: 0,0:00:01.24,0:00:01.28,OP,,0,0,0,,{\\pos(1,1)\\clip(m 2 2)}雨が上がっても',
|
||||
'Dialogue: 0,0:00:01.28,0:00:03.80,OP,,0,0,0,,{\\pos(1,1)\\clip(m 3 3)}雨が上がっても',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
parseAssCues(content).map((cue) => cue.startTime),
|
||||
[1.2, 6],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues withdraws a recovery whose owner is claimed by a later candidate', () => {
|
||||
// The exit boundary event appears first in the file and recovers a canonical cue from
|
||||
// its own small cluster. The entrance candidate then proves that exit event was a
|
||||
// generated frame of the full animation; the earlier recovery is a duplicate of the
|
||||
// same authored line and must not survive alongside it.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 1,0:00:14.00,0:00:14.20,ED,,0,0,0,,{\\move(100,200,20,30)}ABCDEFGH',
|
||||
'Dialogue: 1,0:00:13.50,0:00:14.50,ED,,0,0,0,,{\\t(0,300,\\c&HFFFFFF&)}ABC',
|
||||
'Dialogue: 1,0:00:13.50,0:00:14.50,ED,,0,0,0,,{\\t(300,600,\\c&HFFFFFF&)}DEF',
|
||||
'Dialogue: 1,0:00:13.50,0:00:14.50,ED,,0,0,0,,{\\t(600,900,\\c&HFFFFFF&)}GH',
|
||||
'Dialogue: 0,0:00:10.00,0:00:10.20,ED,,0,0,0,,{\\move(10,20,100,200)}ABCDEFGH',
|
||||
'Dialogue: 0,0:00:10.00,0:00:12.00,ED,,0,0,0,,{\\t(0,300,\\fscx100)}ABC',
|
||||
'Dialogue: 0,0:00:10.00,0:00:12.00,ED,,0,0,0,,{\\t(300,600,\\fscx100)}DEF',
|
||||
'Dialogue: 0,0:00:10.00,0:00:13.40,ED,,0,0,0,,{\\t(600,900,\\fscx100)}GH',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{
|
||||
startTime: 10,
|
||||
endTime: 14.2,
|
||||
text: 'ABCDEFGH',
|
||||
source: 'canonical-ass',
|
||||
animationStartTime: 10,
|
||||
animationEndTime: 14.2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues recovers canonical comments from generated clip frames', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Comment: 0,0:00:01.00,0:00:03.00,OP_JP,,0,0,0,,雨が上がっても',
|
||||
'Dialogue: 0,0:00:01.00,0:00:01.04,OP_JP,,0,0,0,,{\\pos(960,1068)\\clip(m 1 1)}雨が上がっても',
|
||||
'Dialogue: 0,0:00:01.04,0:00:01.08,OP_JP,,0,0,0,,{\\pos(960,1068)\\clip(m 2 2)}雨が上がっても',
|
||||
'Dialogue: 0,0:00:01.08,0:00:03.00,OP_JP,,0,0,0,,{\\pos(960,1068)\\clip(m 3 3)}雨が上がっても',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.deepEqual(cues, [
|
||||
{
|
||||
startTime: 1,
|
||||
endTime: 3,
|
||||
text: '雨が上がっても',
|
||||
source: 'canonical-ass',
|
||||
animationStartTime: 1,
|
||||
animationEndTime: 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues collapses tag-less animation frames in converted SRT', () => {
|
||||
// ASS -> SRT conversion drops override tags, so only the ~0.04s frame timing remains.
|
||||
const lines = ['1', '00:00:07,870 --> 00:00:07,910', 'Kaguya Wants to be Confessed to', ''];
|
||||
|
||||
@@ -6,12 +6,21 @@ import {
|
||||
type AssEffectKind,
|
||||
type AssOverrideCommand,
|
||||
} from './ass-text';
|
||||
import { mergeDuplicateCues } from './subtitle-cue-dedup';
|
||||
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
|
||||
|
||||
export interface SubtitleCue {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
/** A complete authored line recovered from matching generated ASS animation events. */
|
||||
source?: 'canonical-ass';
|
||||
/**
|
||||
* Full span of the generated animation events a canonical cue replaced. Entrance and
|
||||
* exit frames routinely run past the authored `startTime`/`endTime`, so live-text
|
||||
* matching must use this envelope while display and history keep the authored timing.
|
||||
*/
|
||||
animationStartTime?: number;
|
||||
animationEndTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,7 +28,8 @@ export interface SubtitleCue {
|
||||
* Deduplication needs the authoring context -- which style the line belongs to, which
|
||||
* override commands it carries, whether the `Effect` column was set -- to tell a karaoke
|
||||
* burst apart from two characters saying the same word in turn. None of it is meaningful
|
||||
* outside the parser, so the public API stays `{startTime, endTime, text}`.
|
||||
* outside the parser, so the public API exposes only timing, text, and the optional
|
||||
* canonical-source marker used by live subtitle consumers.
|
||||
*/
|
||||
export interface AnnotatedSubtitleCue extends SubtitleCue {
|
||||
/** Text exactly as authored, override blocks and all. */
|
||||
@@ -70,7 +80,11 @@ function sanitizeSubtitleCueText(text: string): string {
|
||||
}
|
||||
|
||||
function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
||||
return cues.map(({ startTime, endTime, text }) => ({ startTime, endTime, text }));
|
||||
return cues.map(({ startTime, endTime, text, source, animationStartTime, animationEndTime }) =>
|
||||
source
|
||||
? { startTime, endTime, text, source, animationStartTime, animationEndTime }
|
||||
: { startTime, endTime, text },
|
||||
);
|
||||
}
|
||||
|
||||
function parseAnnotatedSrtCues(content: string): AnnotatedSubtitleCue[] {
|
||||
@@ -138,7 +152,13 @@ export function parseSrtCues(content: string): SubtitleCue[] {
|
||||
const ASS_TIMING_PATTERN = /^(\d+):(\d{2}):(\d{2})\.(\d{1,2})$/;
|
||||
const ASS_FORMAT_PREFIX = 'Format:';
|
||||
const ASS_DIALOGUE_PREFIX = 'Dialogue:';
|
||||
const ASS_COMMENT_PREFIX = 'Comment:';
|
||||
const ASS_NAME_FIELD_ALIASES = ['name', 'actor'];
|
||||
const CANONICAL_MATCH_MARGIN_SECONDS = 1;
|
||||
const MIN_CANONICAL_ANIMATION_EVENTS = 3;
|
||||
// A tiny animated fragment can itself be composed from still smaller glyph events. It is
|
||||
// not enough evidence that the fragment represents an authored line boundary.
|
||||
const MIN_CANONICAL_DIALOGUE_TEXT_LENGTH = 4;
|
||||
|
||||
function parseAssTimestamp(raw: string): number | null {
|
||||
const match = ASS_TIMING_PATTERN.exec(raw.trim());
|
||||
@@ -166,10 +186,328 @@ function findFieldIndex(formatFields: string[], aliases: string[]): number {
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
interface ParsedAssEvents {
|
||||
dialogue: AnnotatedSubtitleCue[];
|
||||
comments: AnnotatedSubtitleCue[];
|
||||
}
|
||||
|
||||
// Every candidate line re-reads the compacted text of each event in its window, so on
|
||||
// fragment-heavy scripts the same event compacts thousands of times without this cache.
|
||||
const compactMatchTextCache = new WeakMap<AnnotatedSubtitleCue, string>();
|
||||
|
||||
function compactAssMatchText(text: string): string {
|
||||
return text.replace(/\s+/gu, '');
|
||||
}
|
||||
|
||||
function compactCueMatchText(cue: AnnotatedSubtitleCue): string {
|
||||
let compact = compactMatchTextCache.get(cue);
|
||||
if (compact === undefined) {
|
||||
compact = compactAssMatchText(cue.text);
|
||||
compactMatchTextCache.set(cue, compact);
|
||||
}
|
||||
return compact;
|
||||
}
|
||||
|
||||
function assEventGroupKey(cue: AnnotatedSubtitleCue): string {
|
||||
return `${cue.style}\0${cue.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Windowed lookup over one style/name group. Every candidate line queries its time
|
||||
* neighborhood, and fragment-heavy scripts put thousands of candidates in one group, so
|
||||
* a linear rescan per candidate is quadratic in practice. Events are sorted by start
|
||||
* once; `prefixMaxEnd` lets the backward walk stop as soon as no earlier event can still
|
||||
* reach the window.
|
||||
*/
|
||||
interface AssEventGroupIndex {
|
||||
byStart: AnnotatedSubtitleCue[];
|
||||
prefixMaxEnd: number[];
|
||||
}
|
||||
|
||||
function buildAssEventGroupIndex(events: readonly AnnotatedSubtitleCue[]): AssEventGroupIndex {
|
||||
const byStart = [...events].sort((a, b) => a.startTime - b.startTime || a.order - b.order);
|
||||
const prefixMaxEnd: number[] = [];
|
||||
let maxEnd = -Infinity;
|
||||
for (const event of byStart) {
|
||||
maxEnd = Math.max(maxEnd, event.endTime);
|
||||
prefixMaxEnd.push(maxEnd);
|
||||
}
|
||||
return { byStart, prefixMaxEnd };
|
||||
}
|
||||
|
||||
/** Group events overlapping `[startTime, endTime]`, returned in source order. */
|
||||
function eventsOverlappingWindow(
|
||||
index: AssEventGroupIndex,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): AnnotatedSubtitleCue[] {
|
||||
const { byStart, prefixMaxEnd } = index;
|
||||
let low = 0;
|
||||
let high = byStart.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
if (byStart[mid]!.startTime <= endTime) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
const matches: AnnotatedSubtitleCue[] = [];
|
||||
for (let i = low - 1; i >= 0 && prefixMaxEnd[i]! >= startTime; i -= 1) {
|
||||
if (byStart[i]!.endTime >= startTime) {
|
||||
matches.push(byStart[i]!);
|
||||
}
|
||||
}
|
||||
return matches.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
interface FragmentGroup {
|
||||
text: string;
|
||||
events: AnnotatedSubtitleCue[];
|
||||
}
|
||||
|
||||
function fragmentPlacementAnchors(event: AnnotatedSubtitleCue): Set<string> {
|
||||
const anchors = new Set<string>();
|
||||
for (const command of event.overrides) {
|
||||
const name = command.name.toLowerCase();
|
||||
const args = command.args.split(',').map((value) => value.trim());
|
||||
if (name === 'pos' && args.length >= 2) {
|
||||
anchors.add(`pos:${args[0]},${args[1]}`);
|
||||
} else if (name === 'move' && args.length >= 4) {
|
||||
anchors.add(`move:${args[0]},${args[1]}`);
|
||||
anchors.add(`move:${args[2]},${args[3]}`);
|
||||
}
|
||||
}
|
||||
return anchors;
|
||||
}
|
||||
|
||||
function isRepeatedFragmentCopy(
|
||||
previous: AnnotatedSubtitleCue,
|
||||
current: AnnotatedSubtitleCue,
|
||||
): boolean {
|
||||
const previousAnchors = fragmentPlacementAnchors(previous);
|
||||
if ([...fragmentPlacementAnchors(current)].some((anchor) => previousAnchors.has(anchor))) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
previous.startTime === current.startTime &&
|
||||
previous.endTime === current.endTime &&
|
||||
previous.overrideSignature === current.overrideSignature
|
||||
);
|
||||
}
|
||||
|
||||
function groupConsecutiveAssFragments(events: readonly AnnotatedSubtitleCue[]): FragmentGroup[] {
|
||||
const groups: FragmentGroup[] = [];
|
||||
for (const event of events) {
|
||||
const text = compactCueMatchText(event);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const previous = groups.at(-1);
|
||||
if (
|
||||
previous?.text === text &&
|
||||
previous.events.some((previousEvent) => isRepeatedFragmentCopy(previousEvent, event))
|
||||
) {
|
||||
previous.events.push(event);
|
||||
} else {
|
||||
groups.push({ text, events: [event] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function findCanonicalFragmentEvents(
|
||||
events: readonly AnnotatedSubtitleCue[],
|
||||
canonicalText: string,
|
||||
): AnnotatedSubtitleCue[] {
|
||||
const groups = groupConsecutiveAssFragments(events);
|
||||
const matches = new Set<AnnotatedSubtitleCue>();
|
||||
|
||||
for (let start = 0; start < groups.length; start += 1) {
|
||||
let combined = '';
|
||||
for (let end = start; end < groups.length; end += 1) {
|
||||
const group = groups[end]!;
|
||||
// A complete rendered copy cannot prove that the neighboring events are its
|
||||
// fragments. Exact full-line animation is handled separately for comments.
|
||||
if (group.text.length >= canonicalText.length) {
|
||||
break;
|
||||
}
|
||||
const next = combined + group.text;
|
||||
if (!canonicalText.startsWith(next)) {
|
||||
break;
|
||||
}
|
||||
combined = next;
|
||||
if (combined !== canonicalText) {
|
||||
continue;
|
||||
}
|
||||
for (let index = start; index <= end; index += 1) {
|
||||
for (const event of groups[index]!.events) {
|
||||
matches.add(event);
|
||||
}
|
||||
}
|
||||
start = end;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [...matches];
|
||||
}
|
||||
|
||||
function matchingAssAnimationEvents(options: {
|
||||
candidate: AnnotatedSubtitleCue;
|
||||
group: AssEventGroupIndex;
|
||||
allowFullLineFrames: boolean;
|
||||
}): AnnotatedSubtitleCue[] {
|
||||
const canonicalText = compactCueMatchText(options.candidate);
|
||||
// The group index already restricts to the candidate's style and name.
|
||||
const nearby = eventsOverlappingWindow(
|
||||
options.group,
|
||||
options.candidate.startTime - CANONICAL_MATCH_MARGIN_SECONDS,
|
||||
options.candidate.endTime + CANONICAL_MATCH_MARGIN_SECONDS,
|
||||
);
|
||||
const fragments = findCanonicalFragmentEvents(nearby, canonicalText);
|
||||
if (fragments.length >= MIN_CANONICAL_ANIMATION_EVENTS && hasAssAnimationEvidence(fragments)) {
|
||||
return fragments;
|
||||
}
|
||||
|
||||
if (!options.allowFullLineFrames) {
|
||||
return [];
|
||||
}
|
||||
const fullLineFrames = nearby.filter((cue) => compactCueMatchText(cue) === canonicalText);
|
||||
return fullLineFrames.length >= MIN_CANONICAL_ANIMATION_EVENTS &&
|
||||
hasAssAnimationEvidence(fullLineFrames)
|
||||
? fullLineFrames
|
||||
: [];
|
||||
}
|
||||
|
||||
function includeCanonicalBoundaryEvents(options: {
|
||||
candidate: AnnotatedSubtitleCue;
|
||||
group: AssEventGroupIndex;
|
||||
animationEvents: readonly AnnotatedSubtitleCue[];
|
||||
}): AnnotatedSubtitleCue[] {
|
||||
const canonicalText = compactCueMatchText(options.candidate);
|
||||
const startTime = Math.min(...options.animationEvents.map((event) => event.startTime));
|
||||
const endTime = Math.max(...options.animationEvents.map((event) => event.endTime));
|
||||
return eventsOverlappingWindow(
|
||||
options.group,
|
||||
startTime - CANONICAL_MATCH_MARGIN_SECONDS,
|
||||
endTime + CANONICAL_MATCH_MARGIN_SECONDS,
|
||||
).filter((cue) => compactCueMatchText(cue) === canonicalText);
|
||||
}
|
||||
|
||||
function recoverCanonicalAssEvents({
|
||||
dialogue,
|
||||
comments,
|
||||
}: ParsedAssEvents): AnnotatedSubtitleCue[] {
|
||||
const recovered: AnnotatedSubtitleCue[] = [];
|
||||
const suppressed = new Set<AnnotatedSubtitleCue>();
|
||||
// A recovery is only as good as its owning event. When a later candidate proves that
|
||||
// an earlier candidate was itself a generated frame of its animation, the earlier
|
||||
// recovery is a duplicate of the same authored line and must be withdrawn.
|
||||
const recoveredByOwner = new Map<AnnotatedSubtitleCue, AnnotatedSubtitleCue>();
|
||||
const withdrawn = new Set<AnnotatedSubtitleCue>();
|
||||
const eventsByGroup = new Map<string, AnnotatedSubtitleCue[]>();
|
||||
for (const cue of dialogue) {
|
||||
const key = assEventGroupKey(cue);
|
||||
const group = eventsByGroup.get(key);
|
||||
if (group) {
|
||||
group.push(cue);
|
||||
} else {
|
||||
eventsByGroup.set(key, [cue]);
|
||||
}
|
||||
}
|
||||
const indexByGroup = new Map<string, AssEventGroupIndex>();
|
||||
for (const [key, events] of eventsByGroup) {
|
||||
indexByGroup.set(key, buildAssEventGroupIndex(events));
|
||||
}
|
||||
const emptyGroupIndex: AssEventGroupIndex = { byStart: [], prefixMaxEnd: [] };
|
||||
const candidates = [
|
||||
...comments.map((cue) => ({ cue, kind: 'comment' as const })),
|
||||
...dialogue
|
||||
.filter(
|
||||
(cue) =>
|
||||
compactCueMatchText(cue).length >= MIN_CANONICAL_DIALOGUE_TEXT_LENGTH &&
|
||||
hasAssAnimationEvidence([cue]),
|
||||
)
|
||||
.sort((left, right) => right.text.length - left.text.length || left.order - right.order)
|
||||
.map((cue) => ({ cue, kind: 'dialogue' as const })),
|
||||
];
|
||||
|
||||
for (const { cue: candidate, kind } of candidates) {
|
||||
if (candidate.endTime <= candidate.startTime || suppressed.has(candidate)) {
|
||||
continue;
|
||||
}
|
||||
const canonicalText = compactCueMatchText(candidate);
|
||||
if (!canonicalText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const group = indexByGroup.get(assEventGroupKey(candidate)) ?? emptyGroupIndex;
|
||||
const animationEvents = matchingAssAnimationEvents({
|
||||
candidate,
|
||||
group,
|
||||
allowFullLineFrames: kind === 'comment',
|
||||
});
|
||||
if (animationEvents.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const boundaryEvents = includeCanonicalBoundaryEvents({
|
||||
candidate,
|
||||
group,
|
||||
animationEvents,
|
||||
});
|
||||
const generatedEvents = [...new Set([...animationEvents, ...boundaryEvents])];
|
||||
const animationStartTime = Math.min(
|
||||
candidate.startTime,
|
||||
...generatedEvents.map((event) => event.startTime),
|
||||
);
|
||||
const animationEndTime = Math.max(
|
||||
candidate.endTime,
|
||||
...generatedEvents.map((event) => event.endTime),
|
||||
);
|
||||
const startTime = kind === 'comment' ? candidate.startTime : animationStartTime;
|
||||
const endTime = kind === 'comment' ? candidate.endTime : animationEndTime;
|
||||
const recoveredCue: AnnotatedSubtitleCue = {
|
||||
...candidate,
|
||||
startTime,
|
||||
endTime,
|
||||
animationStartTime,
|
||||
animationEndTime,
|
||||
source: 'canonical-ass',
|
||||
};
|
||||
recovered.push(recoveredCue);
|
||||
recoveredByOwner.set(candidate, recoveredCue);
|
||||
for (const event of generatedEvents) {
|
||||
suppressed.add(event);
|
||||
if (event === candidate) {
|
||||
continue;
|
||||
}
|
||||
const priorRecovery = recoveredByOwner.get(event);
|
||||
if (priorRecovery) {
|
||||
// No text is lost by withdrawing: a fragment claim means the withdrawn line is
|
||||
// a contiguous piece of this candidate's text, and a boundary claim means the
|
||||
// texts are equal, so the surviving canonical cue always contains it.
|
||||
withdrawn.add(priorRecovery);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const survivingRecovered = recovered.filter((cue) => !withdrawn.has(cue));
|
||||
if (survivingRecovered.length === 0) {
|
||||
return dialogue;
|
||||
}
|
||||
return [...dialogue.filter((cue) => !suppressed.has(cue)), ...survivingRecovered].sort(
|
||||
(a, b) => a.startTime - b.startTime || a.endTime - b.endTime || a.order - b.order,
|
||||
);
|
||||
}
|
||||
|
||||
function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
const cues: AnnotatedSubtitleCue[] = [];
|
||||
const comments: AnnotatedSubtitleCue[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
let inEventsSection = false;
|
||||
let eventOrder = 0;
|
||||
const fieldIndex = {
|
||||
start: -1,
|
||||
end: -1,
|
||||
@@ -222,7 +560,12 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!trimmed.startsWith(ASS_DIALOGUE_PREFIX)) {
|
||||
const eventPrefix = trimmed.startsWith(ASS_DIALOGUE_PREFIX)
|
||||
? ASS_DIALOGUE_PREFIX
|
||||
: trimmed.startsWith(ASS_COMMENT_PREFIX)
|
||||
? ASS_COMMENT_PREFIX
|
||||
: null;
|
||||
if (!eventPrefix) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -230,7 +573,7 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fields = trimmed.slice(ASS_DIALOGUE_PREFIX.length).split(',');
|
||||
const fields = trimmed.slice(eventPrefix.length).split(',');
|
||||
if (
|
||||
fieldIndex.start >= fields.length ||
|
||||
fieldIndex.end >= fields.length ||
|
||||
@@ -254,7 +597,7 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
const effect = readField(fields, fieldIndex.effect);
|
||||
const layer = Number(readField(fields, fieldIndex.layer));
|
||||
const overrides = collectAssOverrideCommands(rawText);
|
||||
cues.push({
|
||||
const cue: AnnotatedSubtitleCue = {
|
||||
startTime,
|
||||
endTime,
|
||||
text,
|
||||
@@ -266,11 +609,21 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
effectKind: parseAssEffectField(effect),
|
||||
overrides,
|
||||
overrideSignature: assOverrideSignature(overrides),
|
||||
order: cues.length,
|
||||
});
|
||||
order: eventOrder,
|
||||
};
|
||||
eventOrder += 1;
|
||||
if (eventPrefix === ASS_COMMENT_PREFIX) {
|
||||
comments.push(cue);
|
||||
} else {
|
||||
cues.push(cue);
|
||||
}
|
||||
}
|
||||
|
||||
return cues;
|
||||
return { dialogue: cues, comments };
|
||||
}
|
||||
|
||||
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
return recoverCanonicalAssEvents(parseAnnotatedAssEvents(content));
|
||||
}
|
||||
|
||||
export function parseAssCues(content: string): SubtitleCue[] {
|
||||
|
||||
Reference in New Issue
Block a user