mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-06 07:21:33 -07:00
fix(subtitles): cap ASS \t nesting depth to prevent stack overflow
- guard override-block recursion in parseOverrideBlock at 8 levels; pathologically nested \t(...) tags no longer blow the call stack - extract duplicate/animation-burst cue collapsing out of subtitle-cue-parser.ts into subtitle-cue-dedup.ts - add regression tests for deep \t nesting, SRT/VTT brace stripping, and ASS content sniffed behind a lying .srt extension
This commit is contained in:
@@ -127,6 +127,18 @@ test('collectAssOverrideCommands marks tags animated by a wrapping \\t', () => {
|
|||||||
assert.equal(hasAssTemporalOverride(commands), true);
|
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', () => {
|
test('hasAssTemporalOverride ignores static placement and shape tags', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
hasAssTemporalOverride(collectAssOverrideCommands('{\\pos(1,2)\\clip(m 1 1)\\blur2}文字')),
|
hasAssTemporalOverride(collectAssOverrideCommands('{\\pos(1,2)\\clip(m 1 1)\\blur2}文字')),
|
||||||
|
|||||||
@@ -175,7 +175,17 @@ function readCommandArgs(block: string, start: number): { args: string; next: nu
|
|||||||
return { args: block.slice(start, end), next: end };
|
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;
|
let cursor = 0;
|
||||||
|
|
||||||
while (cursor < block.length) {
|
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);
|
const { args, next } = readCommandArgs(block, cursor + 1 + name.length);
|
||||||
into.push({ name, args: args.trim(), animated });
|
into.push({ name, args: args.trim(), animated });
|
||||||
// `\t(0,500,\frz30)` animates whatever it wraps, so record the inner tags too.
|
// `\t(0,500,\frz30)` animates whatever it wraps, so record the inner tags too.
|
||||||
if (name === 't' && args.includes('\\')) {
|
if (name === 't' && args.includes('\\') && depth < MAX_ANIMATION_NESTING_DEPTH) {
|
||||||
parseOverrideBlock(args, true, into);
|
parseOverrideBlock(args, true, into, depth + 1);
|
||||||
}
|
}
|
||||||
cursor = next;
|
cursor = next;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string>();
|
||||||
|
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<string, number[]>();
|
||||||
|
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<number>();
|
||||||
|
const extendedEnd = new Map<number, number>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -91,6 +91,17 @@ test('parseSrtCues skips malformed timing lines gracefully', () => {
|
|||||||
assert.equal(cues[0]!.text, '有効');
|
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', () => {
|
test('parseAssCues parses basic ASS dialogue lines', () => {
|
||||||
const content = [
|
const content = [
|
||||||
'[Script Info]',
|
'[Script Info]',
|
||||||
@@ -617,6 +628,26 @@ test('parseSubtitleCues keeps a short SRT frame run below the minimum length', (
|
|||||||
assert.equal(cues.length, 4);
|
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', () => {
|
test('parseSubtitleCues detects subtitle formats from remote URLs', () => {
|
||||||
const assContent = [
|
const assContent = [
|
||||||
'[Events]',
|
'[Events]',
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import {
|
|||||||
assOverrideSignature,
|
assOverrideSignature,
|
||||||
assToPlainText,
|
assToPlainText,
|
||||||
collectAssOverrideCommands,
|
collectAssOverrideCommands,
|
||||||
hasAssTemporalOverride,
|
|
||||||
isAnimatedAssEffectKind,
|
|
||||||
parseAssEffectField,
|
parseAssEffectField,
|
||||||
type AssEffectKind,
|
type AssEffectKind,
|
||||||
type AssOverrideCommand,
|
type AssOverrideCommand,
|
||||||
} from './ass-text';
|
} from './ass-text';
|
||||||
|
import { mergeDuplicateCues } from './subtitle-cue-dedup';
|
||||||
|
|
||||||
export interface SubtitleCue {
|
export interface SubtitleCue {
|
||||||
startTime: number;
|
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
|
* 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
|
* 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
|
* 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 stays `{startTime, endTime, text}`.
|
||||||
*/
|
*/
|
||||||
interface AnnotatedSubtitleCue extends SubtitleCue {
|
export interface AnnotatedSubtitleCue extends SubtitleCue {
|
||||||
/** Text exactly as authored, override blocks and all. */
|
/** Text exactly as authored, override blocks and all. */
|
||||||
rawText: string;
|
rawText: string;
|
||||||
style: string;
|
style: string;
|
||||||
@@ -40,7 +39,7 @@ interface AnnotatedSubtitleCue extends SubtitleCue {
|
|||||||
order: number;
|
order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubtitleSourceFormat = 'ass' | 'srt';
|
export type SubtitleSourceFormat = 'ass' | 'srt';
|
||||||
|
|
||||||
const HTML_SUBTITLE_TAG_PATTERN = /<\/?[A-Za-z][^>\n]*>/g;
|
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 rawText = textLines.join('\n');
|
||||||
const text = sanitizeSubtitleCueText(rawText);
|
const text = sanitizeSubtitleCueText(rawText);
|
||||||
if (text) {
|
if (text) {
|
||||||
const overrides = collectAssOverrideCommands(rawText);
|
|
||||||
cues.push({
|
cues.push({
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
@@ -121,8 +119,10 @@ function parseAnnotatedSrtCues(content: string): AnnotatedSubtitleCue[] {
|
|||||||
name: '',
|
name: '',
|
||||||
effect: '',
|
effect: '',
|
||||||
effectKind: 'none',
|
effectKind: 'none',
|
||||||
overrides,
|
// SRT and VTT carry no authoring metadata, and the dedup engine never reads
|
||||||
overrideSignature: assOverrideSignature(overrides),
|
// overrides for those formats -- collecting them would be parsing for nobody.
|
||||||
|
overrides: [],
|
||||||
|
overrideSignature: '',
|
||||||
order: cues.length,
|
order: cues.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -293,174 +293,6 @@ function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | n
|
|||||||
return null;
|
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<string>();
|
|
||||||
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<string, number[]>();
|
|
||||||
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<number>();
|
|
||||||
const extendedEnd = new Map<number, number>();
|
|
||||||
|
|
||||||
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[] {
|
export function parseSubtitleCues(content: string, filename: string): SubtitleCue[] {
|
||||||
const format = detectSubtitleFormat(filename);
|
const format = detectSubtitleFormat(filename);
|
||||||
let cues: AnnotatedSubtitleCue[];
|
let cues: AnnotatedSubtitleCue[];
|
||||||
|
|||||||
@@ -861,9 +861,10 @@ export async function tokenizeSubtitle(
|
|||||||
): Promise<SubtitleData> {
|
): Promise<SubtitleData> {
|
||||||
const displayText = normalizePlainSubtitleText(text);
|
const displayText = normalizePlainSubtitleText(text);
|
||||||
|
|
||||||
// Return the normalized form even when it is empty: handing back the original would put
|
// ASS decoding already happened upstream (cue parser for files, mpv for live text), so
|
||||||
// whatever normalization dropped -- a drawing payload, a stray override block -- into
|
// all this drops is whitespace -- but a whitespace-only line still normalizes to empty.
|
||||||
// application state as if it were subtitle text.
|
// 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) {
|
if (!displayText) {
|
||||||
return { text: displayText, tokens: null };
|
return { text: displayText, tokens: null };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user