fix(subtitles): recover canonical lines from ASS animation (#207)

This commit is contained in:
2026-08-18 00:30:48 -07:00
committed by GitHub
parent 14c0086520
commit 273652f781
26 changed files with 1844 additions and 124 deletions
@@ -1,7 +1,4 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { Database } from '../sqlite.js';
import type { DatabaseSync } from '../sqlite.js';
@@ -21,17 +18,6 @@ interface SeedLine {
createdMs?: number;
}
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-duplicate-line-test-'));
return path.join(dir, 'immersion.sqlite');
}
function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) return;
fs.rmSync(dir, { recursive: true, force: true });
}
/** One episode, two sessions of it, and one word occurrence per seeded line. */
function seed(db: DatabaseSync, lines: SeedLine[]): void {
db.exec(`
@@ -82,12 +68,16 @@ function seed(db: DatabaseSync, lines: SeedLine[]): void {
`);
}
function createDb(lines: SeedLine[]): { db: DatabaseSync; dbPath: string } {
const dbPath = makeDbPath();
const db = new Database(dbPath);
/**
* These tests exercise the cleanup SQL, not durability. A fresh on-disk database per
* test pays a schema-creation fsync that is cheap on a local NVMe but slow enough on CI
* runners to blow the 5s per-test timeout, so the database stays in memory.
*/
function createDb(lines: SeedLine[]): { db: DatabaseSync } {
const db = new Database(':memory:');
ensureSchema(db);
seed(db, lines);
return { db, dbPath };
return { db };
}
/** A typeset line mpv reported once per animation frame. */
@@ -119,7 +109,7 @@ function wordFrequency(db: DatabaseSync): number {
}
test('a karaoke burst collapses to one line and gives back its word counts', () => {
const { db, dbPath } = createDb([
const { db } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 40, 40),
{ session: 1, text: 'おはよう', startMs: 20_000, endMs: 22_000 },
]);
@@ -148,7 +138,6 @@ test('a karaoke burst collapses to one line and gives back its word counts', ()
assert.equal(summary.samples[0]!.videoTitle, 'Ep 1');
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
@@ -160,7 +149,7 @@ test('ordinary repeated dialogue survives', () => {
startMs: 5_000 + index * 800,
endMs: 5_000 + (index + 1) * 800,
}));
const { db, dbPath } = createDb(lines);
const { db } = createDb(lines);
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -171,14 +160,13 @@ test('ordinary repeated dialogue survives', () => {
assert.equal(wordFrequency(db), 6);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a long run of quarter-second frames is still a burst', () => {
// Between the timing-only bound (0.1s) and the animation-frame bound (0.3s): heavier
// typesetting lands here, and the run length is what makes it conclusive.
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -189,12 +177,11 @@ test('a long run of quarter-second frames is still a burst', () => {
assert.equal(wordFrequency(db), 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a qualifying short-frame burst may end with one long hold frame', () => {
const { db, dbPath } = createDb([
const { db } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 8, 40),
{ session: 1, text: '飛び上がる', startMs: 10_320, endMs: 12_320 },
]);
@@ -208,12 +195,11 @@ test('a qualifying short-frame burst may end with one long hold frame', () => {
assert.equal(wordFrequency(db), 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a long event before the final frame prevents burst cleanup', () => {
const { db, dbPath } = createDb([
const { db } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 5, 40),
{ session: 1, text: '飛び上がる', startMs: 10_200, endMs: 12_200 },
{ session: 1, text: '飛び上がる', startMs: 12_200, endMs: 12_240 },
@@ -226,12 +212,11 @@ test('a long event before the final frame prevents burst cleanup', () => {
assert.equal(countLines(db), 7);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a run of frames longer than the animation bound survives', () => {
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 400));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 400));
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -240,7 +225,6 @@ test('a run of frames longer than the animation bound survives', () => {
assert.equal(countLines(db), 6);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
@@ -248,7 +232,7 @@ test('the four-frame residue the live gate stores is cleaned up', () => {
// The streaming gate records the first four frames of a burst before the run is long
// enough to recognise. Four contiguous identical events under the strict timing-only
// bound are that residue, and no real dialogue.
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 40));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 40));
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -259,14 +243,13 @@ test('the four-frame residue the live gate stores is cleaned up', () => {
assert.equal(wordFrequency(db), 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a four-frame run above the strict frame bound survives', () => {
// Long enough per event to be plausible dialogue; only a five-event run may use the
// looser animation-frame bound.
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 250));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 250));
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -275,14 +258,13 @@ test('a four-frame run above the strict frame bound survives', () => {
assert.equal(countLines(db), 4);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('an explicit minRunLength raises the bar', () => {
// Five quarter-second frames qualify under the defaults; a cautious run asking for six
// leaves them alone. Above the strict bound, so the residue rule stays out of it.
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 5, 250));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 5, 250));
try {
const preview = cleanupDuplicateSubtitleLines(db, { dryRun: true });
@@ -293,12 +275,11 @@ test('an explicit minRunLength raises the bar', () => {
assert.equal(countLines(db), 5);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('an explicit maxFrameSeconds tightens the frame bound', () => {
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));
try {
const summary = cleanupDuplicateSubtitleLines(db, { maxFrameSeconds: 0.2 });
@@ -307,13 +288,12 @@ test('an explicit maxFrameSeconds tightens the frame bound', () => {
assert.equal(countLines(db), 6);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a non-finite maxFrameSeconds falls back to the default bound', () => {
// Six normal-beat lines: Infinity must not turn every event into a "short frame".
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 800));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 800));
try {
const summary = cleanupDuplicateSubtitleLines(db, { maxFrameSeconds: Infinity });
@@ -322,12 +302,11 @@ test('a non-finite maxFrameSeconds falls back to the default bound', () => {
assert.equal(countLines(db), 6);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('sampleLimit zero removes bursts but reports no samples', () => {
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));
try {
const summary = cleanupDuplicateSubtitleLines(db, { sampleLimit: 0 });
@@ -337,12 +316,11 @@ test('sampleLimit zero removes bursts but reports no samples', () => {
assert.equal(countLines(db), 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a short run below every threshold survives', () => {
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 3, 40));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 3, 40));
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -351,7 +329,6 @@ test('a short run below every threshold survives', () => {
assert.equal(countLines(db), 3);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
@@ -361,7 +338,7 @@ test('interleaved dual-line karaoke collapses each line to one row', () => {
const kanji = karaokeFrames(1, '飛び上がる', 10_000, 20, 60);
const romaji = karaokeFrames(1, 'tobiagaru', 10_001, 20, 60);
const interleaved = [...kanji, ...romaji].sort((a, b) => a.startMs - b.startMs);
const { db, dbPath } = createDb(interleaved);
const { db } = createDb(interleaved);
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -372,12 +349,11 @@ test('interleaved dual-line karaoke collapses each line to one row', () => {
assert.equal(wordFrequency(db), 2);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('the same line in a rewatch session is never merged into the first watch', () => {
const { db, dbPath } = createDb([
const { db } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 6, 40),
...karaokeFrames(2, '飛び上がる', 10_000, 6, 40),
]);
@@ -392,12 +368,11 @@ test('the same line in a rewatch session is never merged into the first watch',
assert.equal(wordFrequency(db), 2);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a gap between runs splits them', () => {
const { db, dbPath } = createDb([
const { db } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 6, 40),
...karaokeFrames(1, '飛び上がる', 60_000, 6, 40),
]);
@@ -409,12 +384,11 @@ test('a gap between runs splits them', () => {
assert.equal(countLines(db), 2);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a dry run reports what an apply would do and writes nothing', () => {
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));
try {
const preview = cleanupDuplicateSubtitleLines(db, { dryRun: true });
@@ -430,14 +404,13 @@ test('a dry run reports what an apply would do and writes nothing', () => {
assert.equal(countLines(db), 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('the lookback window leaves older bursts alone', () => {
const recentMs = BASE_MS;
const oldMs = BASE_MS - 40 * DAY_MS;
const { db, dbPath } = createDb([
const { db } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 6, 40).map((line) => ({
...line,
createdMs: oldMs,
@@ -462,6 +435,5 @@ test('the lookback window leaves older bursts alone', () => {
} finally {
globalThis.__subminerTestNowMs = undefined;
db.close();
cleanupDbPath(dbPath);
}
});
+43
View File
@@ -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[] = [];
+8 -6
View File
@@ -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,
);
}
+181 -7
View File
@@ -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', ''];
+368 -10
View File
@@ -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,333 @@ 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
: [];
}
// Reductions rather than `Math.min(...events)`: one generated line can carry an
// unbounded number of events, and spreading them all as arguments risks the engine's
// argument-count limit.
function earliestStartTime(events: readonly AnnotatedSubtitleCue[], seed = Infinity): number {
return events.reduce((earliest, event) => Math.min(earliest, event.startTime), seed);
}
function latestEndTime(events: readonly AnnotatedSubtitleCue[], seed = -Infinity): number {
return events.reduce((latest, event) => Math.max(latest, event.endTime), seed);
}
function includeCanonicalBoundaryEvents(options: {
candidate: AnnotatedSubtitleCue;
group: AssEventGroupIndex;
animationEvents: readonly AnnotatedSubtitleCue[];
}): AnnotatedSubtitleCue[] {
const canonicalText = compactCueMatchText(options.candidate);
const startTime = earliestStartTime(options.animationEvents);
const endTime = latestEndTime(options.animationEvents);
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 = earliestStartTime(generatedEvents, candidate.startTime);
const animationEndTime = latestEndTime(generatedEvents, candidate.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 +565,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 +578,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 +602,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 +614,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[] {