mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-23 00:15:26 -07:00
- Only trim indentation before slicing ASS event fields so a trailing authored space at a fragment's end is kept, preventing words from being joined together when fragmented karaoke lines are reconstructed - Add regression test covering event-boundary word spacing
1007 lines
31 KiB
TypeScript
1007 lines
31 KiB
TypeScript
import {
|
|
assOverrideSignature,
|
|
assToPlainText,
|
|
collectAssOverrideCommands,
|
|
parseAssEffectField,
|
|
type AssEffectKind,
|
|
type AssOverrideCommand,
|
|
} from './ass-text';
|
|
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
|
|
|
|
export type AssCueLayout =
|
|
| { kind: 'positioned'; sourceOrder: number; y: number }
|
|
| { kind: 'fragment-grid'; sourceOrder: number }
|
|
| { kind: 'source-order'; sourceOrder: number };
|
|
|
|
export interface SubtitleCue {
|
|
startTime: number;
|
|
endTime: number;
|
|
text: string;
|
|
/** How a complete line was recovered from generated ASS animation events. */
|
|
source?: 'canonical-ass' | 'reconstructed-ass';
|
|
/**
|
|
* Full span of the generated animation events a recovered cue replaced. Entrance and
|
|
* exit frames can run past canonical authored timing.
|
|
*/
|
|
animationStartTime?: number;
|
|
animationEndTime?: number;
|
|
/** ASS style retained only for fragment-reconstructed lines. */
|
|
assStyle?: string;
|
|
/** Authored ASS ordering metadata used when flattening simultaneous positioned cues. */
|
|
assLayout?: AssCueLayout;
|
|
}
|
|
|
|
/**
|
|
* 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 exposes only timing, text, and the optional
|
|
* recovery marker used by live subtitle consumers.
|
|
*/
|
|
export interface AnnotatedSubtitleCue extends SubtitleCue {
|
|
/** Text exactly as authored, override blocks and all. */
|
|
rawText: string;
|
|
style: string;
|
|
layer: number;
|
|
/** ASS `Name`/`Actor` column. */
|
|
name: string;
|
|
/** ASS `Effect` column, verbatim. */
|
|
effect: string;
|
|
effectKind: AssEffectKind;
|
|
/** Override commands found in `{...}` blocks, with their arguments. */
|
|
overrides: readonly AssOverrideCommand[];
|
|
/** Canonical form of `overrides`, for spotting values that change across a run. */
|
|
overrideSignature: string;
|
|
/** Position in the source file, so sorting by time stays deterministic across layers. */
|
|
order: number;
|
|
}
|
|
|
|
export type SubtitleSourceFormat = 'ass' | 'srt';
|
|
|
|
const HTML_SUBTITLE_TAG_PATTERN = /<\/?[A-Za-z][^>\n]*>/g;
|
|
|
|
const SRT_TIMING_PATTERN =
|
|
/^\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})/;
|
|
|
|
function parseTimestamp(
|
|
hours: string | undefined,
|
|
minutes: string,
|
|
seconds: string,
|
|
millis: string,
|
|
): number {
|
|
return (
|
|
Number(hours || 0) * 3600 +
|
|
Number(minutes) * 60 +
|
|
Number(seconds) +
|
|
Number(millis.padEnd(3, '0')) / 1000
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The single ASS decode for the file path: cues leave the parser as plain text with real
|
|
* line breaks, matching what mpv hands over for the same line played live. No layer
|
|
* downstream decodes ASS again.
|
|
*/
|
|
function decodeSubtitleCueText(text: string): string {
|
|
return assToPlainText(text, '\n').replace(HTML_SUBTITLE_TAG_PATTERN, '');
|
|
}
|
|
|
|
function sanitizeSubtitleCueText(text: string): string {
|
|
return decodeSubtitleCueText(text).trim();
|
|
}
|
|
|
|
function attachAssLayout<T extends SubtitleCue>(cue: T, assLayout: AssCueLayout | undefined): T {
|
|
if (assLayout) {
|
|
Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false });
|
|
}
|
|
return cue;
|
|
}
|
|
|
|
function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
|
return cues.map(
|
|
({
|
|
startTime,
|
|
endTime,
|
|
text,
|
|
source,
|
|
animationStartTime,
|
|
animationEndTime,
|
|
style,
|
|
assLayout,
|
|
}) => {
|
|
const common = {
|
|
startTime,
|
|
endTime,
|
|
text,
|
|
};
|
|
if (source === 'reconstructed-ass') {
|
|
return attachAssLayout(
|
|
{
|
|
...common,
|
|
source,
|
|
animationStartTime,
|
|
animationEndTime,
|
|
assStyle: style,
|
|
},
|
|
assLayout,
|
|
);
|
|
}
|
|
return attachAssLayout(
|
|
source ? { ...common, source, animationStartTime, animationEndTime } : common,
|
|
assLayout,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
function parseAnnotatedSrtCues(content: string): AnnotatedSubtitleCue[] {
|
|
const cues: AnnotatedSubtitleCue[] = [];
|
|
const lines = content.split(/\r?\n/);
|
|
let i = 0;
|
|
|
|
while (i < lines.length) {
|
|
const line = lines[i]!;
|
|
const timingMatch = SRT_TIMING_PATTERN.exec(line);
|
|
if (!timingMatch) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
const startTime = parseTimestamp(
|
|
timingMatch[1],
|
|
timingMatch[2]!,
|
|
timingMatch[3]!,
|
|
timingMatch[4]!,
|
|
);
|
|
const endTime = parseTimestamp(
|
|
timingMatch[5],
|
|
timingMatch[6]!,
|
|
timingMatch[7]!,
|
|
timingMatch[8]!,
|
|
);
|
|
|
|
i += 1;
|
|
const textLines: string[] = [];
|
|
while (i < lines.length && lines[i]!.trim() !== '') {
|
|
textLines.push(lines[i]!);
|
|
i += 1;
|
|
}
|
|
|
|
const rawText = textLines.join('\n');
|
|
const text = sanitizeSubtitleCueText(rawText);
|
|
if (text) {
|
|
cues.push({
|
|
startTime,
|
|
endTime,
|
|
text,
|
|
rawText,
|
|
style: '',
|
|
layer: 0,
|
|
name: '',
|
|
effect: '',
|
|
effectKind: 'none',
|
|
// 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,
|
|
});
|
|
}
|
|
}
|
|
|
|
return cues;
|
|
}
|
|
|
|
export function parseSrtCues(content: string): SubtitleCue[] {
|
|
return toPublicCues(parseAnnotatedSrtCues(content));
|
|
}
|
|
|
|
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;
|
|
const MIN_FRAGMENT_LINE_EVENTS = 8;
|
|
const MIN_FRAGMENT_LINE_PARTS = 4;
|
|
const MAX_FRAGMENT_MEDIAN_LENGTH = 4;
|
|
const MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS = 2;
|
|
const MAX_FRAGMENT_LINE_VERTICAL_SPAN = 48;
|
|
|
|
function parseAssTimestamp(raw: string): number | null {
|
|
const match = ASS_TIMING_PATTERN.exec(raw.trim());
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const hours = Number(match[1]);
|
|
const minutes = Number(match[2]);
|
|
const seconds = Number(match[3]);
|
|
const centiseconds = Number(match[4]!.padEnd(2, '0'));
|
|
return hours * 3600 + minutes * 60 + seconds + centiseconds / 100;
|
|
}
|
|
|
|
function readField(fields: string[], index: number): string {
|
|
return index >= 0 && index < fields.length ? fields[index]!.trim() : '';
|
|
}
|
|
|
|
function findFieldIndex(formatFields: string[], aliases: string[]): number {
|
|
for (const alias of aliases) {
|
|
const index = formatFields.indexOf(alias);
|
|
if (index >= 0) {
|
|
return index;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
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 hasRelaxedAssFragmentEvidence(events: readonly AnnotatedSubtitleCue[]): boolean {
|
|
if (events.length < 2 || !events.every((event) => fragmentPlacementAnchors(event).size > 0)) {
|
|
return false;
|
|
}
|
|
|
|
const latestStart = events.reduce(
|
|
(latest, event) => Math.max(latest, event.startTime),
|
|
-Infinity,
|
|
);
|
|
const earliestEnd = events.reduce(
|
|
(earliest, event) => Math.min(earliest, event.endTime),
|
|
Infinity,
|
|
);
|
|
if (latestStart >= earliestEnd) {
|
|
return false;
|
|
}
|
|
|
|
const first = events[0]!;
|
|
const hasChangingOverrides = events.some(
|
|
(event) => event.overrideSignature !== first.overrideSignature,
|
|
);
|
|
const hasPositionedLayerCopy = events.some((event, index) =>
|
|
events
|
|
.slice(0, index)
|
|
.some(
|
|
(previous) =>
|
|
compactCueMatchText(previous) === compactCueMatchText(event) &&
|
|
isRepeatedFragmentCopy(previous, event),
|
|
),
|
|
);
|
|
return hasChangingOverrides || hasPositionedLayerCopy;
|
|
}
|
|
|
|
interface AssFragmentPart {
|
|
cue: AnnotatedSubtitleCue;
|
|
text: string;
|
|
}
|
|
|
|
function reconstructedAssFragmentLayout(
|
|
parts: readonly AssFragmentPart[],
|
|
owner: AnnotatedSubtitleCue,
|
|
): AssCueLayout | undefined {
|
|
let positionedPartCount = 0;
|
|
let minimumY = Infinity;
|
|
let maximumY = -Infinity;
|
|
for (const part of parts) {
|
|
const layout = part.cue.assLayout;
|
|
if (layout?.kind !== 'positioned') continue;
|
|
positionedPartCount += 1;
|
|
minimumY = Math.min(minimumY, layout.y);
|
|
maximumY = Math.max(maximumY, layout.y);
|
|
}
|
|
|
|
if (
|
|
positionedPartCount >= MIN_FRAGMENT_LINE_PARTS &&
|
|
maximumY - minimumY > MAX_FRAGMENT_LINE_VERTICAL_SPAN
|
|
) {
|
|
return { kind: 'fragment-grid', sourceOrder: owner.order };
|
|
}
|
|
return owner.assLayout;
|
|
}
|
|
|
|
interface AssFragmentTimingCluster {
|
|
events: AnnotatedSubtitleCue[];
|
|
minStartTime: number;
|
|
maxStartTime: number;
|
|
minEndTime: number;
|
|
maxEndTime: number;
|
|
}
|
|
|
|
function addToFragmentTimingCluster(
|
|
cluster: AssFragmentTimingCluster,
|
|
cue: AnnotatedSubtitleCue,
|
|
): void {
|
|
cluster.events.push(cue);
|
|
cluster.minStartTime = Math.min(cluster.minStartTime, cue.startTime);
|
|
cluster.maxStartTime = Math.max(cluster.maxStartTime, cue.startTime);
|
|
cluster.minEndTime = Math.min(cluster.minEndTime, cue.endTime);
|
|
cluster.maxEndTime = Math.max(cluster.maxEndTime, cue.endTime);
|
|
}
|
|
|
|
function fragmentTimingDistance(
|
|
cluster: AssFragmentTimingCluster,
|
|
cue: AnnotatedSubtitleCue,
|
|
): number {
|
|
const nextMinStart = Math.min(cluster.minStartTime, cue.startTime);
|
|
const nextMaxStart = Math.max(cluster.maxStartTime, cue.startTime);
|
|
const nextMinEnd = Math.min(cluster.minEndTime, cue.endTime);
|
|
const nextMaxEnd = Math.max(cluster.maxEndTime, cue.endTime);
|
|
if (
|
|
nextMaxStart - nextMinStart > MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS ||
|
|
nextMaxEnd - nextMinEnd > MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS
|
|
) {
|
|
return Infinity;
|
|
}
|
|
return (
|
|
Math.abs(cue.startTime - (cluster.minStartTime + cluster.maxStartTime) / 2) +
|
|
Math.abs(cue.endTime - (cluster.minEndTime + cluster.maxEndTime) / 2)
|
|
);
|
|
}
|
|
|
|
function clusterAssFragmentEvents(
|
|
events: readonly AnnotatedSubtitleCue[],
|
|
): AssFragmentTimingCluster[] {
|
|
const clusters: AssFragmentTimingCluster[] = [];
|
|
for (const cue of events) {
|
|
let nearest: AssFragmentTimingCluster | null = null;
|
|
let nearestDistance = Infinity;
|
|
for (const cluster of clusters) {
|
|
const distance = fragmentTimingDistance(cluster, cue);
|
|
if (distance < nearestDistance) {
|
|
nearest = cluster;
|
|
nearestDistance = distance;
|
|
}
|
|
}
|
|
if (nearest) {
|
|
addToFragmentTimingCluster(nearest, cue);
|
|
} else {
|
|
clusters.push({
|
|
events: [cue],
|
|
minStartTime: cue.startTime,
|
|
maxStartTime: cue.startTime,
|
|
minEndTime: cue.endTime,
|
|
maxEndTime: cue.endTime,
|
|
});
|
|
}
|
|
}
|
|
return clusters;
|
|
}
|
|
|
|
function decodeSingleAssFragment(cue: AnnotatedSubtitleCue): string | null {
|
|
const visibleLines = decodeSubtitleCueText(cue.rawText)
|
|
.split('\n')
|
|
.filter((line) => line.trim().length > 0);
|
|
return visibleLines.length === 1 ? visibleLines[0]! : null;
|
|
}
|
|
|
|
function reconstructAssFragmentLine(
|
|
events: readonly AnnotatedSubtitleCue[],
|
|
): AnnotatedSubtitleCue | null {
|
|
const hasRelaxedEvidence = hasRelaxedAssFragmentEvidence(events);
|
|
const minimumEvents = hasRelaxedEvidence ? 2 : MIN_FRAGMENT_LINE_EVENTS;
|
|
if (events.length < minimumEvents || !hasAssAnimationEvidence(events)) {
|
|
return null;
|
|
}
|
|
|
|
const parts: AssFragmentPart[] = [];
|
|
for (const cue of events) {
|
|
const text = decodeSingleAssFragment(cue);
|
|
if (text === null) {
|
|
return null;
|
|
}
|
|
const compactText = compactAssMatchText(text);
|
|
const isLayerCopy = parts.some(
|
|
(part) =>
|
|
compactAssMatchText(part.text) === compactText && isRepeatedFragmentCopy(part.cue, cue),
|
|
);
|
|
if (!isLayerCopy) {
|
|
parts.push({ cue, text });
|
|
}
|
|
}
|
|
|
|
const minimumParts = hasRelaxedEvidence ? 1 : MIN_FRAGMENT_LINE_PARTS;
|
|
if (parts.length < minimumParts || (!hasRelaxedEvidence && parts.length === events.length)) {
|
|
return null;
|
|
}
|
|
const lengths = parts
|
|
.map((part) => compactAssMatchText(part.text).length)
|
|
.sort((left, right) => left - right);
|
|
if ((lengths[Math.floor(lengths.length / 2)] ?? Infinity) > MAX_FRAGMENT_MEDIAN_LENGTH) {
|
|
return null;
|
|
}
|
|
|
|
const text = parts
|
|
.map((part) => part.text)
|
|
.join('')
|
|
.trim();
|
|
if (!text) {
|
|
return null;
|
|
}
|
|
const owner = parts[0]!.cue;
|
|
const animationStartTime = earliestStartTime(events);
|
|
const animationEndTime = latestEndTime(events);
|
|
return {
|
|
...owner,
|
|
startTime: animationStartTime,
|
|
endTime: animationEndTime,
|
|
text,
|
|
rawText: text,
|
|
source: 'reconstructed-ass',
|
|
animationStartTime,
|
|
animationEndTime,
|
|
assLayout: reconstructedAssFragmentLayout(parts, owner),
|
|
overrides: [],
|
|
overrideSignature: '',
|
|
};
|
|
}
|
|
|
|
function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
|
|
const groups = new Map<string, AnnotatedSubtitleCue[]>();
|
|
for (const cue of dialogue) {
|
|
if (cue.source !== undefined) {
|
|
continue;
|
|
}
|
|
const key = assEventGroupKey(cue);
|
|
const group = groups.get(key);
|
|
if (group) {
|
|
group.push(cue);
|
|
} else {
|
|
groups.set(key, [cue]);
|
|
}
|
|
}
|
|
|
|
const recovered: AnnotatedSubtitleCue[] = [];
|
|
const suppressed = new Set<AnnotatedSubtitleCue>();
|
|
for (const events of groups.values()) {
|
|
for (const cluster of clusterAssFragmentEvents(events)) {
|
|
const line = reconstructAssFragmentLine(cluster.events);
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
recovered.push(line);
|
|
cluster.events.forEach((event) => suppressed.add(event));
|
|
}
|
|
}
|
|
if (recovered.length === 0) {
|
|
return dialogue;
|
|
}
|
|
return [...dialogue.filter((cue) => !suppressed.has(cue)), ...recovered].sort(
|
|
(left, right) =>
|
|
left.startTime - right.startTime || left.endTime - right.endTime || left.order - right.order,
|
|
);
|
|
}
|
|
|
|
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 parseAssCoordinate(value: string | undefined): number | null {
|
|
if (!value?.trim()) return null;
|
|
const coordinate = Number(value.trim());
|
|
return Number.isFinite(coordinate) ? coordinate : null;
|
|
}
|
|
|
|
function buildAssCueLayout(
|
|
overrides: readonly AssOverrideCommand[],
|
|
sourceOrder: number,
|
|
): AssCueLayout {
|
|
let y: number | null = null;
|
|
for (const command of overrides) {
|
|
if (command.animated) continue;
|
|
const name = command.name.toLowerCase();
|
|
const args = command.args.split(',');
|
|
if (name === 'pos') {
|
|
y = parseAssCoordinate(args[1]) ?? y;
|
|
continue;
|
|
}
|
|
if (name !== 'move') continue;
|
|
const startY = parseAssCoordinate(args[1]);
|
|
const endY = parseAssCoordinate(args[3]);
|
|
if (startY !== null && endY !== null) {
|
|
y = (startY + endY) / 2;
|
|
}
|
|
}
|
|
return y === null
|
|
? { kind: 'source-order', sourceOrder }
|
|
: { kind: 'positioned', sourceOrder, y };
|
|
}
|
|
|
|
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,
|
|
text: -1,
|
|
style: -1,
|
|
layer: -1,
|
|
name: -1,
|
|
effect: -1,
|
|
};
|
|
|
|
const resetFieldIndex = () => {
|
|
fieldIndex.start = -1;
|
|
fieldIndex.end = -1;
|
|
fieldIndex.text = -1;
|
|
fieldIndex.style = -1;
|
|
fieldIndex.layer = -1;
|
|
fieldIndex.name = -1;
|
|
fieldIndex.effect = -1;
|
|
};
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
// Event text can end in an authored space. Fragmented karaoke commonly uses that
|
|
// space to retain word boundaries when its separately positioned events are joined
|
|
// back into a line, so only remove indentation before slicing the event fields.
|
|
const eventLine = line.trimStart();
|
|
|
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
inEventsSection = trimmed.toLowerCase() === '[events]';
|
|
if (!inEventsSection) {
|
|
resetFieldIndex();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!inEventsSection) {
|
|
continue;
|
|
}
|
|
|
|
if (trimmed.startsWith(ASS_FORMAT_PREFIX)) {
|
|
const formatFields = trimmed
|
|
.slice(ASS_FORMAT_PREFIX.length)
|
|
.split(',')
|
|
.map((field) => field.trim().toLowerCase());
|
|
fieldIndex.start = formatFields.indexOf('start');
|
|
fieldIndex.end = formatFields.indexOf('end');
|
|
fieldIndex.text = formatFields.indexOf('text');
|
|
fieldIndex.style = formatFields.indexOf('style');
|
|
fieldIndex.layer = formatFields.indexOf('layer');
|
|
// Aegisub writes the speaker column as `Actor`; the v4+ spec calls it `Name`.
|
|
// Missing it costs the burst check its speaker guard, so both spellings count.
|
|
fieldIndex.name = findFieldIndex(formatFields, ASS_NAME_FIELD_ALIASES);
|
|
fieldIndex.effect = formatFields.indexOf('effect');
|
|
continue;
|
|
}
|
|
|
|
const eventPrefix = eventLine.startsWith(ASS_DIALOGUE_PREFIX)
|
|
? ASS_DIALOGUE_PREFIX
|
|
: eventLine.startsWith(ASS_COMMENT_PREFIX)
|
|
? ASS_COMMENT_PREFIX
|
|
: null;
|
|
if (!eventPrefix) {
|
|
continue;
|
|
}
|
|
|
|
if (fieldIndex.start < 0 || fieldIndex.end < 0 || fieldIndex.text < 0) {
|
|
continue;
|
|
}
|
|
|
|
const fields = eventLine.slice(eventPrefix.length).split(',');
|
|
if (
|
|
fieldIndex.start >= fields.length ||
|
|
fieldIndex.end >= fields.length ||
|
|
fieldIndex.text >= fields.length
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const startTime = parseAssTimestamp(fields[fieldIndex.start]!);
|
|
const endTime = parseAssTimestamp(fields[fieldIndex.end]!);
|
|
if (startTime === null || endTime === null) {
|
|
continue;
|
|
}
|
|
|
|
const rawText = fields.slice(fieldIndex.text).join(',');
|
|
const text = sanitizeSubtitleCueText(rawText);
|
|
if (!text) {
|
|
continue;
|
|
}
|
|
|
|
const effect = readField(fields, fieldIndex.effect);
|
|
const layer = Number(readField(fields, fieldIndex.layer));
|
|
const overrides = collectAssOverrideCommands(rawText);
|
|
const cue: AnnotatedSubtitleCue = {
|
|
startTime,
|
|
endTime,
|
|
text,
|
|
rawText,
|
|
style: readField(fields, fieldIndex.style),
|
|
layer: Number.isFinite(layer) ? layer : 0,
|
|
name: readField(fields, fieldIndex.name),
|
|
effect,
|
|
effectKind: parseAssEffectField(effect),
|
|
overrides,
|
|
overrideSignature: assOverrideSignature(overrides),
|
|
order: eventOrder,
|
|
assLayout: buildAssCueLayout(overrides, eventOrder),
|
|
};
|
|
eventOrder += 1;
|
|
if (eventPrefix === ASS_COMMENT_PREFIX) {
|
|
comments.push(cue);
|
|
} else {
|
|
cues.push(cue);
|
|
}
|
|
}
|
|
|
|
return { dialogue: cues, comments };
|
|
}
|
|
|
|
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
|
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(parseAnnotatedAssEvents(content)));
|
|
}
|
|
|
|
export function parseAssCues(content: string): SubtitleCue[] {
|
|
return toPublicCues(parseAnnotatedAssCues(content));
|
|
}
|
|
|
|
function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null {
|
|
const [normalizedSource = source] =
|
|
(() => {
|
|
try {
|
|
return /^[a-z]+:\/\//i.test(source) ? new URL(source).pathname : source;
|
|
} catch {
|
|
return source;
|
|
}
|
|
})().split(/[?#]/, 1)[0] ?? '';
|
|
const ext = normalizedSource.split('.').pop()?.toLowerCase() ?? '';
|
|
if (ext === 'srt') return 'srt';
|
|
if (ext === 'vtt') return 'vtt';
|
|
if (ext === 'ass' || ext === 'ssa') return 'ass';
|
|
return null;
|
|
}
|
|
|
|
export function parseSubtitleCues(content: string, filename: string): SubtitleCue[] {
|
|
const format = detectSubtitleFormat(filename);
|
|
let cues: AnnotatedSubtitleCue[];
|
|
let sourceFormat: SubtitleSourceFormat = 'srt';
|
|
|
|
switch (format) {
|
|
case 'srt':
|
|
case 'vtt':
|
|
cues = parseAnnotatedSrtCues(content);
|
|
break;
|
|
case 'ass':
|
|
case 'ssa':
|
|
cues = parseAnnotatedAssCues(content);
|
|
sourceFormat = 'ass';
|
|
break;
|
|
default:
|
|
cues = [];
|
|
}
|
|
|
|
if (cues.length === 0) {
|
|
const assCues = parseAnnotatedAssCues(content);
|
|
const srtCues = parseAnnotatedSrtCues(content);
|
|
const preferAss = assCues.length >= srtCues.length;
|
|
cues = preferAss ? assCues : srtCues;
|
|
sourceFormat = preferAss && assCues.length > 0 ? 'ass' : 'srt';
|
|
}
|
|
|
|
cues.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime || a.order - b.order);
|
|
return toPublicCues(mergeDuplicateCues(cues, sourceFormat));
|
|
}
|