mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-13 01:55:50 -07:00
326 lines
9.3 KiB
TypeScript
326 lines
9.3 KiB
TypeScript
import {
|
|
assOverrideSignature,
|
|
assToPlainText,
|
|
collectAssOverrideCommands,
|
|
parseAssEffectField,
|
|
type AssEffectKind,
|
|
type AssOverrideCommand,
|
|
} from './ass-text';
|
|
import { mergeDuplicateCues } from './subtitle-cue-dedup';
|
|
|
|
export interface SubtitleCue {
|
|
startTime: number;
|
|
endTime: number;
|
|
text: string;
|
|
}
|
|
|
|
/**
|
|
* 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}`.
|
|
*/
|
|
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 sanitizeSubtitleCueText(text: string): string {
|
|
return assToPlainText(text, '\n').replace(HTML_SUBTITLE_TAG_PATTERN, '').trim();
|
|
}
|
|
|
|
function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
|
return cues.map(({ startTime, endTime, text }) => ({ startTime, endTime, text }));
|
|
}
|
|
|
|
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_NAME_FIELD_ALIASES = ['name', 'actor'];
|
|
|
|
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;
|
|
}
|
|
|
|
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
|
const cues: AnnotatedSubtitleCue[] = [];
|
|
const lines = content.split(/\r?\n/);
|
|
let inEventsSection = false;
|
|
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();
|
|
|
|
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;
|
|
}
|
|
|
|
if (!trimmed.startsWith(ASS_DIALOGUE_PREFIX)) {
|
|
continue;
|
|
}
|
|
|
|
if (fieldIndex.start < 0 || fieldIndex.end < 0 || fieldIndex.text < 0) {
|
|
continue;
|
|
}
|
|
|
|
const fields = trimmed.slice(ASS_DIALOGUE_PREFIX.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);
|
|
cues.push({
|
|
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: cues.length,
|
|
});
|
|
}
|
|
|
|
return cues;
|
|
}
|
|
|
|
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));
|
|
}
|