diff --git a/src/core/services/ass-text.test.ts b/src/core/services/ass-text.test.ts index 0b14751b..fc51f6e5 100644 --- a/src/core/services/ass-text.test.ts +++ b/src/core/services/ass-text.test.ts @@ -127,6 +127,18 @@ test('collectAssOverrideCommands marks tags animated by a wrapping \\t', () => { assert.equal(hasAssTemporalOverride(commands), true); }); +test('collectAssOverrideCommands survives pathologically nested \\t tags', () => { + const depth = 200000; + const block = `{${'\\t(0,500,'.repeat(depth)}\\frz30${')'.repeat(depth)}}文字`; + + const commands = collectAssOverrideCommands(block); + + // Recursion stops at the nesting cap; the outer tags are still reported, and nothing + // blows the call stack. + assert.equal(commands[0]!.name, 't'); + assert.equal(hasAssTemporalOverride(commands), true); +}); + test('hasAssTemporalOverride ignores static placement and shape tags', () => { assert.equal( hasAssTemporalOverride(collectAssOverrideCommands('{\\pos(1,2)\\clip(m 1 1)\\blur2}文字')), diff --git a/src/core/services/ass-text.ts b/src/core/services/ass-text.ts index dc89c986..ca05b7fd 100644 --- a/src/core/services/ass-text.ts +++ b/src/core/services/ass-text.ts @@ -175,7 +175,17 @@ function readCommandArgs(block: string, start: number): { args: string; next: nu return { args: block.slice(start, end), next: end }; } -function parseOverrideBlock(block: string, animated: boolean, into: AssOverrideCommand[]): void { +// `\t(...)` can wrap another `\t(...)`, and nothing in the format stops an author (or a +// malformed file) from nesting them thousands deep. Real typesetting never goes past one +// or two levels, so stop recursing well before the call stack is at risk. +const MAX_ANIMATION_NESTING_DEPTH = 8; + +function parseOverrideBlock( + block: string, + animated: boolean, + into: AssOverrideCommand[], + depth = 0, +): void { let cursor = 0; while (cursor < block.length) { @@ -195,8 +205,8 @@ function parseOverrideBlock(block: string, animated: boolean, into: AssOverrideC const { args, next } = readCommandArgs(block, cursor + 1 + name.length); into.push({ name, args: args.trim(), animated }); // `\t(0,500,\frz30)` animates whatever it wraps, so record the inner tags too. - if (name === 't' && args.includes('\\')) { - parseOverrideBlock(args, true, into); + if (name === 't' && args.includes('\\') && depth < MAX_ANIMATION_NESTING_DEPTH) { + parseOverrideBlock(args, true, into, depth + 1); } cursor = next; } diff --git a/src/core/services/subtitle-cue-dedup.ts b/src/core/services/subtitle-cue-dedup.ts new file mode 100644 index 00000000..ff8f70ca --- /dev/null +++ b/src/core/services/subtitle-cue-dedup.ts @@ -0,0 +1,185 @@ +/* + * Duplicate/animation-burst collapsing for parsed subtitle cues. + * + * Split out of the cue parser so the parsing rules and the "is this run one animation?" + * heuristics can be read -- and tested -- on their own. The parser owns the cue shape; + * this module only decides which cues survive. + */ + +import { hasAssTemporalOverride, isAnimatedAssEffectKind } from './ass-text'; +import type { + AnnotatedSubtitleCue, + SubtitleCue, + SubtitleSourceFormat, +} from './subtitle-cue-parser'; + +// Back-to-back frames of the same animation are authored flush against each other; a +// tiny tolerance absorbs the centisecond rounding of the ASS timestamp format. +const DUPLICATE_CUE_GAP_TOLERANCE_SECONDS = 0.05; +// A burst is a *sequence*. Two adjacent events are two events, not an animation -- +// characters do repeat each other, and a repeated line can legitimately be short. +const MIN_BURST_EVENTS = 3; +// Real dialogue holds on screen for about a second, so a run with a couple of much +// shorter events among them looks like frames. Used only alongside authoring evidence. +const ANIMATION_FRAME_MAX_SECONDS = 0.3; +// A karaoke run usually ends on a long "hold" frame, so not every event is short. +const MIN_TAGGED_BURST_FRAMES = 2; +// SRT and VTT carry no authoring metadata at all, so timing is the only signal available +// -- which makes it the easiest one to get wrong. ASS->SRT conversion leaves frames at +// ~0.04s, well under any real utterance, and a burst leaves many of them behind. Both +// bounds are deliberately far stricter than the ASS path: a run of ordinary short lines +// (`えっ` traded between characters) must not clear them. +const TIMING_ONLY_FRAME_MAX_SECONDS = 0.1; +const MIN_TIMING_ONLY_FRAMES = 5; + +function cueKey(cue: SubtitleCue): string { + return `${cue.startTime}|${cue.endTime}|${cue.text}`; +} + +/** + * 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. + */ +function collapseExactDuplicates(cues: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] { + const seen = new Set(); + return cues.filter((cue) => { + const key = cueKey(cue); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +function countFramesShorterThan(run: AnnotatedSubtitleCue[], maxSeconds: number): number { + return run.filter((cue) => cue.endTime - cue.startTime < maxSeconds).length; +} + +/** + * Evidence that a run of ASS events is one animation rather than several authored lines. + * A static tag says nothing on its own -- three events sharing one `\clip(...)` are three + * signs -- so the tag has to be temporal by nature (`\t`, `\move`, karaoke timing, or + * anything wrapped in `\t(...)`), an animated `Effect` column, or a value that actually + * changes from event to event, which is how per-frame typesetting is authored. + */ +export function hasAssAnimationEvidence(run: AnnotatedSubtitleCue[]): boolean { + if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) { + return true; + } + if (run.every((cue) => isAnimatedAssEffectKind(cue.effectKind))) { + return true; + } + + const [first] = run; + const everyEventTypeset = run.every((cue) => cue.overrides.length > 0); + const signatureChanges = run.some((cue) => cue.overrideSignature !== first!.overrideSignature); + return everyEventTypeset && signatureChanges; +} + +export function isAnimationBurst( + run: AnnotatedSubtitleCue[], + format: SubtitleSourceFormat, +): boolean { + if (run.length < MIN_BURST_EVENTS) { + return false; + } + + if (format === 'srt') { + return ( + run.length >= MIN_TIMING_ONLY_FRAMES && + countFramesShorterThan(run, TIMING_ONLY_FRAME_MAX_SECONDS) === run.length + ); + } + + if (countFramesShorterThan(run, ANIMATION_FRAME_MAX_SECONDS) < MIN_TAGGED_BURST_FRAMES) { + return false; + } + + // One animation belongs to one styled, one named source line. Two characters trading + // the same short word are two styles or two actors, and never merge. + const [first] = run; + if (run.some((cue) => cue.style !== first!.style || cue.name !== first!.name)) { + return false; + } + + return hasAssAnimationEvidence(run); +} + +/** + * Karaoke and sign typesetting emits one Dialogue event per animation frame, all carrying + * the same visible text over a contiguous span. Collapse each such run into a single cue. + * + * Only runs that look like animation collapse. Two ordinary lines that happen to repeat + * -- several characters each saying `おはよう` in turn, a positioned sign redrawn with a + * different fade -- stay separate, because merging them would destroy real mineable lines. + */ +function collapseAnimationBursts( + cues: AnnotatedSubtitleCue[], + format: SubtitleSourceFormat, +): AnnotatedSubtitleCue[] { + const indicesByText = new Map(); + cues.forEach((cue, index) => { + const bucket = indicesByText.get(cue.text); + if (bucket) { + bucket.push(index); + } else { + indicesByText.set(cue.text, [index]); + } + }); + + const dropped = new Set(); + const extendedEnd = new Map(); + + for (const indices of indicesByText.values()) { + if (indices.length < MIN_BURST_EVENTS) { + continue; + } + + let runStart = 0; + while (runStart < indices.length) { + let runEnd = runStart; + let chainEnd = cues[indices[runStart]!]!.endTime; + + while (runEnd + 1 < indices.length) { + const next = cues[indices[runEnd + 1]!]!; + if (next.startTime > chainEnd + DUPLICATE_CUE_GAP_TOLERANCE_SECONDS) { + break; + } + chainEnd = Math.max(chainEnd, next.endTime); + runEnd += 1; + } + + const run = indices.slice(runStart, runEnd + 1).map((index) => cues[index]!); + if (isAnimationBurst(run, format)) { + for (let i = runStart + 1; i <= runEnd; i += 1) { + dropped.add(indices[i]!); + } + extendedEnd.set(indices[runStart]!, chainEnd); + } + + runStart = runEnd + 1; + } + } + + if (dropped.size === 0) { + return cues; + } + + const merged: AnnotatedSubtitleCue[] = []; + cues.forEach((cue, index) => { + if (dropped.has(index)) { + return; + } + const end = extendedEnd.get(index); + merged.push(end !== undefined && end > cue.endTime ? { ...cue, endTime: end } : cue); + }); + return merged; +} + +export function mergeDuplicateCues( + cues: AnnotatedSubtitleCue[], + format: SubtitleSourceFormat, +): AnnotatedSubtitleCue[] { + return collapseAnimationBursts(collapseExactDuplicates(cues), format); +} diff --git a/src/core/services/subtitle-cue-parser.test.ts b/src/core/services/subtitle-cue-parser.test.ts index 46097ae7..86738d04 100644 --- a/src/core/services/subtitle-cue-parser.test.ts +++ b/src/core/services/subtitle-cue-parser.test.ts @@ -91,6 +91,17 @@ test('parseSrtCues skips malformed timing lines gracefully', () => { assert.equal(cues[0]!.text, '有効'); }); +test('parseSubtitleCues strips complete brace blocks from SRT and VTT text', () => { + const content = ['1', '00:00:01,000 --> 00:00:02,000', '彼は{謎}と言った', ''].join('\n'); + + for (const filename of ['test.srt', 'test.vtt']) { + const cues = parseSubtitleCues(content, filename); + + assert.equal(cues.length, 1, filename); + assert.equal(cues[0]!.text, '彼はと言った', filename); + } +}); + test('parseAssCues parses basic ASS dialogue lines', () => { const content = [ '[Script Info]', @@ -617,6 +628,26 @@ test('parseSubtitleCues keeps a short SRT frame run below the minimum length', ( assert.equal(cues.length, 4); }); +test('parseSubtitleCues applies ASS burst rules to ASS content behind an .srt filename', () => { + // The extension lies, so the SRT parser finds nothing and the content-sniffing fallback + // takes over -- which has to carry the `ass` source format with it, or the far stricter + // timing-only thresholds would let this karaoke burst through as three cues. + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:01.20,Karaoke,,0,0,0,,{\\k20}歌詞', + 'Dialogue: 0,0:00:01.20,0:00:01.40,Karaoke,,0,0,0,,{\\k20}歌詞', + 'Dialogue: 0,0:00:01.40,0:00:03.00,Karaoke,,0,0,0,,{\\k20}歌詞', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.srt'); + + assert.equal(cues.length, 1); + assert.equal(cues[0]!.startTime, 1.0); + assert.equal(cues[0]!.endTime, 3.0); + assert.equal(cues[0]!.text, '歌詞'); +}); + test('parseSubtitleCues detects subtitle formats from remote URLs', () => { const assContent = [ '[Events]', diff --git a/src/core/services/subtitle-cue-parser.ts b/src/core/services/subtitle-cue-parser.ts index 79496d03..ae5ea0d5 100644 --- a/src/core/services/subtitle-cue-parser.ts +++ b/src/core/services/subtitle-cue-parser.ts @@ -2,12 +2,11 @@ import { assOverrideSignature, assToPlainText, collectAssOverrideCommands, - hasAssTemporalOverride, - isAnimatedAssEffectKind, parseAssEffectField, type AssEffectKind, type AssOverrideCommand, } from './ass-text'; +import { mergeDuplicateCues } from './subtitle-cue-dedup'; export interface SubtitleCue { startTime: number; @@ -16,13 +15,13 @@ export interface SubtitleCue { } /** - * Everything the parser knows about a source event, kept private to this module. + * Everything the parser knows about a source event, shared only with the dedup engine. * 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}`. */ -interface AnnotatedSubtitleCue extends SubtitleCue { +export interface AnnotatedSubtitleCue extends SubtitleCue { /** Text exactly as authored, override blocks and all. */ rawText: string; style: string; @@ -40,7 +39,7 @@ interface AnnotatedSubtitleCue extends SubtitleCue { order: number; } -type SubtitleSourceFormat = 'ass' | 'srt'; +export type SubtitleSourceFormat = 'ass' | 'srt'; const HTML_SUBTITLE_TAG_PATTERN = /<\/?[A-Za-z][^>\n]*>/g; @@ -110,7 +109,6 @@ function parseAnnotatedSrtCues(content: string): AnnotatedSubtitleCue[] { const rawText = textLines.join('\n'); const text = sanitizeSubtitleCueText(rawText); if (text) { - const overrides = collectAssOverrideCommands(rawText); cues.push({ startTime, endTime, @@ -121,8 +119,10 @@ function parseAnnotatedSrtCues(content: string): AnnotatedSubtitleCue[] { name: '', effect: '', effectKind: 'none', - overrides, - overrideSignature: assOverrideSignature(overrides), + // SRT and VTT carry no authoring metadata, and the dedup engine never reads + // overrides for those formats -- collecting them would be parsing for nobody. + overrides: [], + overrideSignature: '', order: cues.length, }); } @@ -293,174 +293,6 @@ function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | n return null; } -// Back-to-back frames of the same animation are authored flush against each other; a -// tiny tolerance absorbs the centisecond rounding of the ASS timestamp format. -const DUPLICATE_CUE_GAP_TOLERANCE_SECONDS = 0.05; -// A burst is a *sequence*. Two adjacent events are two events, not an animation -- -// characters do repeat each other, and a repeated line can legitimately be short. -const MIN_BURST_EVENTS = 3; -// Real dialogue holds on screen for about a second, so a run with a couple of much -// shorter events among them looks like frames. Used only alongside authoring evidence. -const ANIMATION_FRAME_MAX_SECONDS = 0.3; -// A karaoke run usually ends on a long "hold" frame, so not every event is short. -const MIN_TAGGED_BURST_FRAMES = 2; -// SRT and VTT carry no authoring metadata at all, so timing is the only signal available -// -- which makes it the easiest one to get wrong. ASS->SRT conversion leaves frames at -// ~0.04s, well under any real utterance, and a burst leaves many of them behind. Both -// bounds are deliberately far stricter than the ASS path: a run of ordinary short lines -// (`えっ` traded between characters) must not clear them. -const TIMING_ONLY_FRAME_MAX_SECONDS = 0.1; -const MIN_TIMING_ONLY_FRAMES = 5; - -function cueKey(cue: SubtitleCue): string { - return `${cue.startTime}|${cue.endTime}|${cue.text}`; -} - -/** - * 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. - */ -function collapseExactDuplicates(cues: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] { - const seen = new Set(); - return cues.filter((cue) => { - const key = cueKey(cue); - if (seen.has(key)) { - return false; - } - seen.add(key); - return true; - }); -} - -function countFramesShorterThan(run: AnnotatedSubtitleCue[], maxSeconds: number): number { - return run.filter((cue) => cue.endTime - cue.startTime < maxSeconds).length; -} - -/** - * Evidence that a run of ASS events is one animation rather than several authored lines. - * A static tag says nothing on its own -- three events sharing one `\clip(...)` are three - * signs -- so the tag has to be temporal by nature (`\t`, `\move`, karaoke timing, or - * anything wrapped in `\t(...)`), an animated `Effect` column, or a value that actually - * changes from event to event, which is how per-frame typesetting is authored. - */ -function hasAssAnimationEvidence(run: AnnotatedSubtitleCue[]): boolean { - if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) { - return true; - } - if (run.every((cue) => isAnimatedAssEffectKind(cue.effectKind))) { - return true; - } - - const [first] = run; - const everyEventTypeset = run.every((cue) => cue.overrides.length > 0); - const signatureChanges = run.some((cue) => cue.overrideSignature !== first!.overrideSignature); - return everyEventTypeset && signatureChanges; -} - -function isAnimationBurst(run: AnnotatedSubtitleCue[], format: SubtitleSourceFormat): boolean { - if (run.length < MIN_BURST_EVENTS) { - return false; - } - - if (format === 'srt') { - return ( - run.length >= MIN_TIMING_ONLY_FRAMES && - countFramesShorterThan(run, TIMING_ONLY_FRAME_MAX_SECONDS) === run.length - ); - } - - if (countFramesShorterThan(run, ANIMATION_FRAME_MAX_SECONDS) < MIN_TAGGED_BURST_FRAMES) { - return false; - } - - // One animation belongs to one styled, one named source line. Two characters trading - // the same short word are two styles or two actors, and never merge. - const [first] = run; - if (run.some((cue) => cue.style !== first!.style || cue.name !== first!.name)) { - return false; - } - - return hasAssAnimationEvidence(run); -} - -/** - * Karaoke and sign typesetting emits one Dialogue event per animation frame, all carrying - * the same visible text over a contiguous span. Collapse each such run into a single cue. - * - * Only runs that look like animation collapse. Two ordinary lines that happen to repeat - * -- several characters each saying `おはよう` in turn, a positioned sign redrawn with a - * different fade -- stay separate, because merging them would destroy real mineable lines. - */ -function collapseAnimationBursts( - cues: AnnotatedSubtitleCue[], - format: SubtitleSourceFormat, -): AnnotatedSubtitleCue[] { - const indicesByText = new Map(); - cues.forEach((cue, index) => { - const bucket = indicesByText.get(cue.text); - if (bucket) { - bucket.push(index); - } else { - indicesByText.set(cue.text, [index]); - } - }); - - const dropped = new Set(); - const extendedEnd = new Map(); - - for (const indices of indicesByText.values()) { - if (indices.length < MIN_BURST_EVENTS) { - continue; - } - - let runStart = 0; - while (runStart < indices.length) { - let runEnd = runStart; - let chainEnd = cues[indices[runStart]!]!.endTime; - - while (runEnd + 1 < indices.length) { - const next = cues[indices[runEnd + 1]!]!; - if (next.startTime > chainEnd + DUPLICATE_CUE_GAP_TOLERANCE_SECONDS) { - break; - } - chainEnd = Math.max(chainEnd, next.endTime); - runEnd += 1; - } - - const run = indices.slice(runStart, runEnd + 1).map((index) => cues[index]!); - if (isAnimationBurst(run, format)) { - for (let i = runStart + 1; i <= runEnd; i += 1) { - dropped.add(indices[i]!); - } - extendedEnd.set(indices[runStart]!, chainEnd); - } - - runStart = runEnd + 1; - } - } - - if (dropped.size === 0) { - return cues; - } - - const merged: AnnotatedSubtitleCue[] = []; - cues.forEach((cue, index) => { - if (dropped.has(index)) { - return; - } - const end = extendedEnd.get(index); - merged.push(end !== undefined && end > cue.endTime ? { ...cue, endTime: end } : cue); - }); - return merged; -} - -function mergeDuplicateCues( - cues: AnnotatedSubtitleCue[], - format: SubtitleSourceFormat, -): AnnotatedSubtitleCue[] { - return collapseAnimationBursts(collapseExactDuplicates(cues), format); -} - export function parseSubtitleCues(content: string, filename: string): SubtitleCue[] { const format = detectSubtitleFormat(filename); let cues: AnnotatedSubtitleCue[]; diff --git a/src/core/services/tokenizer.ts b/src/core/services/tokenizer.ts index 286caa22..459896ed 100644 --- a/src/core/services/tokenizer.ts +++ b/src/core/services/tokenizer.ts @@ -861,9 +861,10 @@ export async function tokenizeSubtitle( ): Promise { const displayText = normalizePlainSubtitleText(text); - // Return the normalized form even when it is empty: handing back the original would put - // whatever normalization dropped -- a drawing payload, a stray override block -- into - // application state as if it were subtitle text. + // ASS decoding already happened upstream (cue parser for files, mpv for live text), so + // all this drops is whitespace -- but a whitespace-only line still normalizes to empty. + // Return the normalized form anyway: handing back the original would put a blank line + // into application state as if it were subtitle text. if (!displayText) { return { text: displayText, tokens: null }; }