UNPKG

subforge

Version:

High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.

657 lines (600 loc) 22.6 kB
import type { SubtitleDocument, SubtitleEvent, TextSegment, InlineStyle, Effect } from './types.ts' import type { ParseError, ParseWarning, ParseResult, ParseOptions } from './errors.ts' import { createDocument } from './document.ts' import { explodeKaraoke } from './ops.ts' import { toASS } from '../formats/text/ass/serializer.ts' import { toSSA } from '../formats/text/ssa/serializer.ts' import { toSRT } from '../formats/text/srt/serializer.ts' import { toVTT } from '../formats/text/vtt/serializer.ts' import { toSBV } from '../formats/text/sbv/serializer.ts' import { toLRC, type LRCSerializeOptions } from '../formats/text/lrc/serializer.ts' import { toMicroDVD, type MicroDVDSerializeOptions } from '../formats/text/microdvd/serializer.ts' import { toTTML, type TTMLSerializeOptions } from '../formats/xml/ttml/serializer.ts' import { toDFXP } from '../formats/xml/ttml/dfxp.ts' import { toSMPTETT } from '../formats/xml/ttml/smpte.ts' import { toSAMI } from '../formats/xml/sami/serializer.ts' import { toRealText } from '../formats/xml/realtext/serializer.ts' import { toQT, type QTSerializeOptions } from '../formats/xml/qt/serializer.ts' import { toEBUSTL, type EBUSTLSerializeOptions } from '../formats/binary/stl/ebu/serializer.ts' import { toSpruceSTL, type SpruceSTLSerializeOptions } from '../formats/binary/stl/spruce/serializer.ts' import { toPGS } from '../formats/binary/pgs/serializer.ts' import { toDVB } from '../formats/binary/dvb/serializer.ts' import { toVobSub } from '../formats/binary/vobsub/index.ts' import { toPAC, type PACSerializeOptions } from '../formats/binary/pac/serializer.ts' import { toSCC } from '../formats/broadcast/scc/serializer.ts' import { toCAP, type CAPSerializerOptions } from '../formats/broadcast/cap/serializer.ts' import { toTeletext } from '../formats/broadcast/teletext/serializer.ts' import { parseASS } from '../formats/text/ass/parser.ts' import { parseSSA } from '../formats/text/ssa/parser.ts' import { parseSRT } from '../formats/text/srt/parser.ts' import { parseVTT } from '../formats/text/vtt/parser.ts' import { parseSBV } from '../formats/text/sbv/parser.ts' import { parseLRC } from '../formats/text/lrc/parser.ts' import { parseMicroDVD, type MicroDVDParseOptions } from '../formats/text/microdvd/parser.ts' import { parseTTML } from '../formats/xml/ttml/parser.ts' import { parseDFXP } from '../formats/xml/ttml/dfxp.ts' import { parseSMPTETT } from '../formats/xml/ttml/smpte.ts' import { parseSAMI } from '../formats/xml/sami/parser.ts' import { parseRealText } from '../formats/xml/realtext/parser.ts' import { parseQT } from '../formats/xml/qt/parser.ts' import { parseEBUSTL } from '../formats/binary/stl/ebu/parser.ts' import { parseSpruceSTL } from '../formats/binary/stl/spruce/parser.ts' import { parsePGS } from '../formats/binary/pgs/parser.ts' import { parseDVB } from '../formats/binary/dvb/parser.ts' import { parseVobSub } from '../formats/binary/vobsub/index.ts' import { parsePAC } from '../formats/binary/pac/parser.ts' import { parseSCC } from '../formats/broadcast/scc/parser.ts' import { parseCAP } from '../formats/broadcast/cap/parser.ts' import { parseTeletext } from '../formats/broadcast/teletext/parser.ts' import { serializeTags as serializeASSTags } from '../formats/text/ass/tags.ts' export type FormatId = | 'ass' | 'ssa' | 'srt' | 'vtt' | 'sbv' | 'lrc' | 'microdvd' | 'ttml' | 'dfxp' | 'smpte-tt' | 'sami' | 'realtext' | 'qt' | 'ebu-stl' | 'spruce-stl' | 'pgs' | 'dvb' | 'vobsub' | 'pac' | 'scc' | 'cap' | 'teletext' export interface FormatOutputMap { ass: string ssa: string srt: string vtt: string sbv: string lrc: string microdvd: string ttml: string dfxp: string 'smpte-tt': string sami: string realtext: string qt: string 'ebu-stl': Uint8Array 'spruce-stl': string pgs: Uint8Array dvb: Uint8Array vobsub: { idx: string; sub: Uint8Array } pac: Uint8Array scc: string cap: string teletext: Uint8Array } export interface FormatOptionsMap { lrc?: LRCSerializeOptions microdvd?: MicroDVDSerializeOptions ttml?: TTMLSerializeOptions dfxp?: TTMLSerializeOptions 'smpte-tt'?: TTMLSerializeOptions qt?: QTSerializeOptions cap?: CAPSerializerOptions pac?: PACSerializeOptions 'ebu-stl'?: EBUSTLSerializeOptions 'spruce-stl'?: SpruceSTLSerializeOptions } export interface FormatParseOptionsMap { ass?: Partial<ParseOptions> ssa?: Partial<ParseOptions> srt?: Partial<ParseOptions> vtt?: Partial<ParseOptions> sbv?: Partial<ParseOptions> lrc?: Partial<ParseOptions> microdvd?: MicroDVDParseOptions ttml?: Partial<ParseOptions> dfxp?: Partial<ParseOptions> 'smpte-tt'?: Partial<ParseOptions> sami?: Partial<ParseOptions> realtext?: Partial<ParseOptions> qt?: Partial<ParseOptions> 'ebu-stl'?: Partial<ParseOptions> 'spruce-stl'?: Partial<ParseOptions> pgs?: Partial<ParseOptions> dvb?: Partial<ParseOptions> pac?: Partial<ParseOptions> scc?: Partial<ParseOptions> cap?: Partial<ParseOptions> teletext?: Partial<ParseOptions> } /** * Options for converting between subtitle formats. */ export interface ConvertOptions { /** Target format */ to: FormatId /** How to handle unsupported effects: drop them or keep them */ unsupported?: 'drop' | 'comment' /** How to handle karaoke timing: preserve, split into events, or remove */ karaoke?: 'preserve' | 'explode' | 'strip' /** How to handle positioning tags: keep or remove */ positioning?: 'preserve' | 'strip' /** Whether to track and report lost features */ reportLoss?: boolean /** Per-format serializer options */ formatOptions?: FormatOptionsMap } /** * Result of a format conversion. */ export interface ConvertResult<T extends FormatId = FormatId> { /** Converted subtitle file content */ output: FormatOutputMap[T] /** Features that were lost in conversion */ lostFeatures: LostFeature[] } /** * Options for end-to-end transcode (parse + convert). */ export interface TranscodeOptions<T extends FormatId = FormatId> extends Omit<ConvertOptions, 'to'> { /** Source format or 'auto' for best-effort text detection */ from: FormatId | 'auto' /** Target format */ to: T /** Per-format parse options */ parseOptions?: FormatParseOptionsMap } /** * Result of a transcode operation. */ export interface TranscodeResult<T extends FormatId = FormatId> { ok: boolean output?: FormatOutputMap[T] document?: SubtitleDocument errors: ParseError[] warnings: ParseWarning[] lostFeatures: LostFeature[] } /** * A feature that was lost during conversion. */ export interface LostFeature { /** Index of the event that lost features */ eventIndex: number /** Feature type that was lost */ feature: string /** Human-readable description */ description: string } const defaultOptions: Required<Pick<ConvertOptions, 'unsupported' | 'karaoke' | 'positioning' | 'reportLoss'>> = { unsupported: 'drop', karaoke: 'strip', positioning: 'strip', reportLoss: false } type InlineStyleKey = keyof InlineStyle type TextMode = 'segments' | 'plain' | 'ass-tags' | 'images' interface FormatProfile { inlineStyles: Set<InlineStyleKey> | 'all' effects: Set<string> | 'all' textMode: TextMode } const srtStyles = new Set<InlineStyleKey>(['bold', 'italic', 'underline', 'strikeout', 'primaryColor']) const vttStyles = new Set<InlineStyleKey>(['bold', 'italic', 'underline']) const microDVDStyles = new Set<InlineStyleKey>(['bold', 'italic', 'underline', 'strikeout', 'primaryColor', 'fontName', 'fontSize']) const samiStyles = srtStyles const ttmlStyles = new Set<InlineStyleKey>(['fontName', 'fontSize', 'bold', 'italic', 'underline', 'strikeout', 'primaryColor', 'backColor']) const pacStyles = new Set<InlineStyleKey>(['italic', 'underline']) const profileMap: Record<FormatId, FormatProfile> = { ass: { inlineStyles: 'all', effects: 'all', textMode: 'segments' }, ssa: { inlineStyles: 'all', effects: 'all', textMode: 'segments' }, srt: { inlineStyles: srtStyles, effects: new Set(), textMode: 'segments' }, vtt: { inlineStyles: vttStyles, effects: new Set(), textMode: 'segments' }, sbv: { inlineStyles: new Set(), effects: new Set(), textMode: 'plain' }, lrc: { inlineStyles: new Set(), effects: new Set(['karaoke', 'karaokeAbsolute']), textMode: 'segments' }, microdvd: { inlineStyles: microDVDStyles, effects: new Set(), textMode: 'segments' }, ttml: { inlineStyles: ttmlStyles, effects: new Set(), textMode: 'segments' }, dfxp: { inlineStyles: ttmlStyles, effects: new Set(), textMode: 'segments' }, 'smpte-tt': { inlineStyles: ttmlStyles, effects: new Set(), textMode: 'segments' }, sami: { inlineStyles: samiStyles, effects: new Set(), textMode: 'segments' }, realtext: { inlineStyles: new Set(), effects: new Set(), textMode: 'plain' }, qt: { inlineStyles: new Set(), effects: new Set(), textMode: 'plain' }, 'ebu-stl': { inlineStyles: new Set(), effects: new Set(), textMode: 'plain' }, 'spruce-stl': { inlineStyles: new Set(), effects: new Set(), textMode: 'plain' }, pgs: { inlineStyles: new Set(), effects: new Set(['image', 'pgs']), textMode: 'images' }, dvb: { inlineStyles: new Set(), effects: new Set(['image']), textMode: 'images' }, vobsub: { inlineStyles: new Set(), effects: new Set(['image', 'vobsub']), textMode: 'images' }, pac: { inlineStyles: pacStyles, effects: new Set(), textMode: 'ass-tags' }, scc: { inlineStyles: new Set(), effects: new Set(), textMode: 'plain' }, cap: { inlineStyles: new Set(), effects: new Set(), textMode: 'plain' }, teletext: { inlineStyles: new Set(), effects: new Set(), textMode: 'plain' }, } /** * Converts a subtitle document to a different format. * Handles feature compatibility and tracks lost features. * @param doc - Subtitle document to convert * @param opts - Conversion options * @returns Converted output and list of lost features * @example * ```ts * const result = convert(assDoc, { * to: 'srt', * karaoke: 'strip', * positioning: 'strip', * reportLoss: true * }) * console.log(`Lost ${result.lostFeatures.length} features`) * console.log(result.output) * ``` */ export function convert<T extends FormatId>( doc: SubtitleDocument, opts: ConvertOptions & { to: T } ): ConvertResult<T> { const options = { ...defaultOptions, ...opts } const lostFeatures: LostFeature[] = [] const profile = profileMap[options.to] const sourceEvents = options.karaoke === 'explode' ? doc.events.flatMap(event => explodeKaraoke(event)) : doc.events const convertedEvents = sourceEvents.map((event, idx) => convertEvent(event, idx, profile, options, lostFeatures) ) const convertedDoc: SubtitleDocument = { ...doc, events: convertedEvents } const output = serializeByFormat(options.to, convertedDoc, options.formatOptions) return { output: output as FormatOutputMap[T], lostFeatures } } /** * Parse and convert in one step. */ export function transcode<T extends FormatId>( input: string | Uint8Array | ArrayBuffer | { idx: string; sub: Uint8Array | ArrayBuffer }, opts: TranscodeOptions & { to: T } ): TranscodeResult<T> { const detected = opts.from === 'auto' ? detectFormat(input) : opts.from if (!detected) { return { ok: false, errors: [makeParseError('INVALID_SECTION', 'Unable to detect format from input')], warnings: [], lostFeatures: [] } } const parseResult = parseByFormat(detected, input, opts.parseOptions) if (!parseResult.ok) { return { ok: false, document: parseResult.document, errors: parseResult.errors, warnings: parseResult.warnings, lostFeatures: [] } } const conversion = convert(parseResult.document, { ...opts, to: opts.to }) return { ok: true, output: conversion.output, document: parseResult.document, errors: parseResult.errors, warnings: parseResult.warnings, lostFeatures: conversion.lostFeatures } } function convertEvent( event: SubtitleEvent, eventIndex: number, profile: FormatProfile, options: Required<Pick<ConvertOptions, 'unsupported' | 'karaoke' | 'positioning' | 'reportLoss'>>, lostFeatures: LostFeature[] ): SubtitleEvent { const imageSegments = event.segments.length === 0 ? buildImageSegments(event) : null const baseSegments = event.segments.length > 0 ? event.segments : (imageSegments ?? [{ text: event.text, style: null, effects: [] }]) const convertedSegments = baseSegments.map(seg => convertSegment(seg, eventIndex, profile, options, lostFeatures) ) let text = event.text let dirty = event.dirty switch (profile.textMode) { case 'plain': text = plainTextFromSegments(convertedSegments, event.text) dirty = false break case 'ass-tags': text = serializeASSTags(convertedSegments) dirty = false break case 'segments': dirty = convertedSegments.length > 0 break case 'images': dirty = false break } return { ...event, text, segments: convertedSegments, dirty } } function buildImageSegments(event: SubtitleEvent): TextSegment[] | null { if (!event.image) return null const effects: Effect[] = [{ type: 'image', params: event.image }] if (event.vobsub) { effects.push({ type: 'vobsub', params: event.vobsub }) } if (event.pgs) { effects.push({ type: 'pgs', params: event.pgs }) } return [{ text: '', style: null, effects }] } function convertSegment( seg: TextSegment, eventIndex: number, profile: FormatProfile, options: Required<Pick<ConvertOptions, 'unsupported' | 'karaoke' | 'positioning' | 'reportLoss'>>, lostFeatures: LostFeature[] ): TextSegment { const newStyle = seg.style ? { ...seg.style } : null const newEffects: Effect[] = [] if (newStyle) { if (newStyle.pos !== undefined && options.positioning === 'strip') { reportLoss(lostFeatures, eventIndex, 'positioning', `pos(${newStyle.pos[0]},${newStyle.pos[1]})`, options) delete newStyle.pos } if (newStyle.alignment !== undefined && options.positioning === 'strip') { reportLoss(lostFeatures, eventIndex, 'alignment', `an${newStyle.alignment}`, options) delete newStyle.alignment } if (profile.inlineStyles !== 'all') { for (const key of Object.keys(newStyle) as InlineStyleKey[]) { if (!profile.inlineStyles.has(key)) { reportLoss(lostFeatures, eventIndex, String(key), String(key), options) delete (newStyle as Record<string, unknown>)[key] } } } } for (const effect of seg.effects) { if (isKaraokeEffect(effect) && options.karaoke === 'strip') { reportLoss(lostFeatures, eventIndex, 'karaoke', 'karaoke timing', options) continue } if (!isEffectSupported(effect.type, profile)) { reportLoss(lostFeatures, eventIndex, effect.type, `\${effect.type}`, options) if (options.unsupported === 'drop') { continue } } newEffects[newEffects.length] = effect } return { text: seg.text, style: newStyle && Object.keys(newStyle).length > 0 ? newStyle : null, effects: newEffects } } function isEffectSupported(type: string, profile: FormatProfile): boolean { if (profile.effects === 'all') return true return profile.effects.has(type) } function isKaraokeEffect(effect: Effect): boolean { return effect.type === 'karaoke' || effect.type === 'karaokeAbsolute' } function plainTextFromSegments(segments: TextSegment[], fallback: string): string { if (segments.length === 0) return fallback return segments.map(seg => seg.text).join('') } function reportLoss( lostFeatures: LostFeature[], eventIndex: number, feature: string, description: string, options: Required<Pick<ConvertOptions, 'reportLoss'>> ): void { if (!options.reportLoss) return lostFeatures[lostFeatures.length] = { eventIndex, feature, description } } function serializeByFormat(format: FormatId, doc: SubtitleDocument, formatOptions?: FormatOptionsMap): FormatOutputMap[FormatId] { switch (format) { case 'ass': return toASS(doc) case 'ssa': return toSSA(doc) case 'srt': return toSRT(doc) case 'vtt': return toVTT(doc) case 'sbv': return toSBV(doc) case 'lrc': return toLRC(doc, formatOptions?.lrc) case 'microdvd': if (!formatOptions?.microdvd) { throw new Error('microdvd requires formatOptions.microdvd') } return toMicroDVD(doc, formatOptions.microdvd) case 'ttml': return toTTML(doc, formatOptions?.ttml) case 'dfxp': return toDFXP(doc, formatOptions?.dfxp) case 'smpte-tt': return toSMPTETT(doc, formatOptions?.['smpte-tt']) case 'sami': return toSAMI(doc) case 'realtext': return toRealText(doc) case 'qt': return toQT(doc, formatOptions?.qt) case 'ebu-stl': return toEBUSTL(doc, formatOptions?.['ebu-stl']) case 'spruce-stl': return toSpruceSTL(doc, formatOptions?.['spruce-stl']) case 'pgs': return toPGS(doc) case 'dvb': return toDVB(doc) case 'vobsub': return toVobSub(doc) case 'pac': return toPAC(doc, formatOptions?.pac) case 'scc': return toSCC(doc) case 'cap': return toCAP(doc, formatOptions?.cap) case 'teletext': return toTeletext(doc) } } function parseByFormat( format: FormatId, input: string | Uint8Array | ArrayBuffer | { idx: string; sub: Uint8Array | ArrayBuffer }, parseOptions?: FormatParseOptionsMap ): ParseResult { switch (format) { case 'ass': return ensureString(input, format, () => parseASS(input, parseOptions?.ass)) case 'ssa': return ensureString(input, format, () => parseSSA(input, parseOptions?.ssa)) case 'srt': return ensureString(input, format, () => parseSRT(input, parseOptions?.srt)) case 'vtt': return ensureString(input, format, () => parseVTT(input, parseOptions?.vtt)) case 'sbv': return ensureString(input, format, () => parseSBV(input, parseOptions?.sbv)) case 'lrc': return ensureString(input, format, () => parseLRC(input, parseOptions?.lrc)) case 'microdvd': if (typeof input !== 'string') return parseFailure(format, 'Expected string input') if (!parseOptions?.microdvd?.fps) { return parseFailure(format, 'microdvd requires parseOptions.microdvd.fps') } return parseMicroDVD(input, parseOptions.microdvd) case 'ttml': return ensureString(input, format, () => parseTTML(input, parseOptions?.ttml)) case 'dfxp': return ensureString(input, format, () => parseDFXP(input)) case 'smpte-tt': return ensureString(input, format, () => parseSMPTETT(input)) case 'sami': return ensureString(input, format, () => parseSAMI(input, parseOptions?.sami)) case 'realtext': return ensureString(input, format, () => parseRealText(input, parseOptions?.realtext)) case 'qt': return ensureString(input, format, () => parseQT(input, parseOptions?.qt)) case 'ebu-stl': return ensureBinary(input, format, () => parseEBUSTL(asBinary(input), parseOptions?.['ebu-stl'])) case 'spruce-stl': return ensureString(input, format, () => parseSpruceSTL(input, parseOptions?.['spruce-stl'])) case 'pgs': return ensureBinary(input, format, () => parsePGS(asBinary(input), parseOptions?.pgs)) case 'dvb': return ensureBinary(input, format, () => parseDVB(asBinary(input), parseOptions?.dvb)) case 'vobsub': if (!isVobSubInput(input)) return parseFailure(format, 'Expected { idx, sub } input') return parseVobSub(input.idx, input.sub) case 'pac': return ensureBinary(input, format, () => parsePAC(asBinary(input), parseOptions?.pac)) case 'scc': return ensureString(input, format, () => parseSCC(input, parseOptions?.scc)) case 'cap': return ensureString(input, format, () => parseCAP(input, parseOptions?.cap)) case 'teletext': return ensureBinary(input, format, () => parseTeletext(asBinary(input), parseOptions?.teletext)) } } function detectFormat( input: string | Uint8Array | ArrayBuffer | { idx: string; sub: Uint8Array | ArrayBuffer } ): FormatId | null { if (typeof input !== 'string') return null const text = input.trimStart() if (text.startsWith('WEBVTT')) return 'vtt' if (text.startsWith('Scenarist_SCC')) return 'scc' if (text.startsWith('$CaptionMAX')) return 'cap' if (text.startsWith('{QTtext}')) return 'qt' if (text.startsWith('[Script Info]')) return text.includes('V4+ Styles') ? 'ass' : 'ssa' if (text.startsWith('<SAMI') || text.startsWith('<SAMI>')) return 'sami' if (text.startsWith('<tt') || text.includes('<tt ')) return 'ttml' if (text.startsWith('<window')) return 'realtext' if (text.startsWith('{') && text.includes('}{')) return 'microdvd' if (text.includes('-->')) return text.includes(',') ? 'srt' : 'vtt' if (text.includes(',') && /\d+:\d{2}:\d{2}\.\d{3}\s*,/.test(text)) return 'sbv' if (text.includes('[') && /\[\d{2}:\d{2}\.\d{2,3}\]/.test(text)) return 'lrc' return null } function ensureString<T>( input: string | Uint8Array | ArrayBuffer | { idx: string; sub: Uint8Array | ArrayBuffer }, format: FormatId, fn: () => ParseResult ): ParseResult { if (typeof input !== 'string') { return parseFailure(format, 'Expected string input') } return fn() } function ensureBinary( input: string | Uint8Array | ArrayBuffer | { idx: string; sub: Uint8Array | ArrayBuffer }, format: FormatId, fn: () => ParseResult ): ParseResult { if (typeof input === 'string') { return parseFailure(format, 'Expected binary input') } if (isVobSubInput(input)) { return parseFailure(format, 'Expected binary input, not VobSub bundle') } return fn() } function isVobSubInput( input: string | Uint8Array | ArrayBuffer | { idx: string; sub: Uint8Array | ArrayBuffer } ): input is { idx: string; sub: Uint8Array | ArrayBuffer } { return typeof input === 'object' && input !== null && 'idx' in input && 'sub' in input } function asBinary(input: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer { return input } function parseFailure(format: FormatId, message: string): ParseResult { return { ok: false, document: createDocument(), errors: [makeParseError('INVALID_SECTION', `${format}: ${message}`)], warnings: [] } } function makeParseError(code: ParseError['code'], message: string): ParseError { return { line: 1, column: 1, code, message } }