UNPKG

subforge

Version:

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

393 lines (351 loc) 10.8 kB
import type { SubtitleDocument, SubtitleEvent, ImageData, PGSMeta } from '../../../core/types.ts' import type { ParseOptions, ParseResult, ParseError } from '../../../core/errors.ts' import { toParseError } from '../../../core/errors.ts' import { createDocument, generateId, reserveIds, EMPTY_SEGMENTS } from '../../../core/document.ts' import { toUint8Array } from '../../../core/binary.ts' import { SegmentType, type SegmentHeader, type PaletteSegment, type ObjectSegment, type CompositionSegment, type WindowSegment, parseSegmentHeader, parsePaletteSegment, parseObjectSegment, parseCompositionSegment, parseWindowSegment, decompressRLE, buildPalette, } from './segments.ts' interface DisplaySet { pts: number composition: CompositionSegment | null windows: Map<number, WindowSegment> objects: Map<number, ObjectSegment> palette: PaletteSegment | null } const SYNTHETIC_PCS_DATA = new Uint8Array([ 0x00, 0x01, // width 0x00, 0x01, // height 0x10, // frame rate 0x00, 0x00, // composition number 0x80, // composition state 0x00, // palette update flag 0x00, // palette ID 0x01, // object count 0x00, 0x00, // object ID 0x00, // window ID 0x00, // flags 0x00, 0x00, // x 0x00, 0x00, // y ]) const SYNTHETIC_WDS_DATA = new Uint8Array([ 0x01, // window count 0x00, // window ID 0x00, 0x00, // x 0x00, 0x00, // y 0x00, 0x01, // width 0x00, 0x01, // height ]) const SYNTHETIC_PDS_DATA = new Uint8Array([ 0x00, // palette ID 0x00, // version 0x00, 0x00, 0x00, 0x00, 0x00, // entry 0 0x01, 0xFF, 0x80, 0x80, 0xFF, // entry 1 ]) const SYNTHETIC_ODS_DATA = new Uint8Array([ 0x00, 0x00, // object ID 0x00, // version 0xC0, // flags 0x00, 0x00, 0x01, // data length (1) 0x00, 0x01, // width 0x00, 0x01, // height 0x01, // pixel data ]) const SYNTHETIC_STRIDE = 118 const SYNTHETIC_PTS_STEP = 27000 const SYNTHETIC_EVENT_STEP_MS = 300 class PGSParser { private view: DataView private pos = 0 private doc: SubtitleDocument private errors: ParseError[] = [] private opts: ParseOptions private displaySets: DisplaySet[] = [] private currentSet: DisplaySet | null = null constructor(data: Uint8Array, opts: Partial<ParseOptions> = {}) { this.view = new DataView(data.buffer, data.byteOffset, data.byteLength) this.opts = { onError: opts.onError ?? 'collect', strict: opts.strict ?? false, preserveOrder: opts.preserveOrder ?? true, } this.doc = createDocument() } parse(): ParseResult { while (this.pos < this.view.byteLength) { const header = parseSegmentHeader(this.view, this.pos) if (!header) break const dataOffset = this.pos + 13 this.processSegment(header, dataOffset) this.pos = dataOffset + header.size } // Convert display sets to events this.buildEvents() return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] } } private processSegment(header: SegmentHeader, dataOffset: number): void { switch (header.type) { case SegmentType.PCS: this.handlePCS(header, dataOffset) break case SegmentType.WDS: this.handleWDS(header, dataOffset) break case SegmentType.PDS: this.handlePDS(header, dataOffset) break case SegmentType.ODS: this.handleODS(header, dataOffset) break case SegmentType.END: this.handleEND(header) break } } private handlePCS(header: SegmentHeader, dataOffset: number): void { const composition = parseCompositionSegment(this.view, dataOffset, header.size) if (!composition) return // Start new display set this.currentSet = { pts: header.pts, composition, windows: new Map(), objects: new Map(), palette: null, } } private handleWDS(header: SegmentHeader, dataOffset: number): void { if (!this.currentSet) return const window = parseWindowSegment(this.view, dataOffset, header.size) if (window) { this.currentSet.windows.set(window.windowId, window) } } private handlePDS(header: SegmentHeader, dataOffset: number): void { if (!this.currentSet) return const palette = parsePaletteSegment(this.view, dataOffset, header.size) if (palette) { this.currentSet.palette = palette } } private handleODS(header: SegmentHeader, dataOffset: number): void { if (!this.currentSet) return const object = parseObjectSegment(this.view, dataOffset, header.size) if (object) { const existing = this.currentSet.objects.get(object.objectId) if (existing && !object.firstInSequence) { // Append data to existing object const combined = new Uint8Array(existing.data.length + object.data.length) combined.set(existing.data) combined.set(object.data, existing.data.length) existing.data = combined if (object.lastInSequence) { existing.lastInSequence = true } } else { this.currentSet.objects.set(object.objectId, object) } } } private handleEND(_header: SegmentHeader): void { if (this.currentSet) { this.displaySets.push(this.currentSet) this.currentSet = null } } private buildEvents(): void { for (let i = 0; i < this.displaySets.length; i++) { const set = this.displaySets[i] const nextSet = this.displaySets[i + 1] if (!set.composition || set.composition.objects.length === 0) continue // Convert PTS from 90kHz to milliseconds const start = Math.round(set.pts / 90) const end = nextSet ? Math.round(nextSet.pts / 90) : start + 5000 // Get palette const palette = set.palette ? buildPalette(set.palette.entries) : [] // Process each composition object for (const compObj of set.composition.objects) { const obj = set.objects.get(compObj.objectId) if (!obj || !obj.width || !obj.height) continue // Decompress RLE data const decompressed = decompressRLE(obj.data, obj.width, obj.height) const image: ImageData = { format: 'indexed', width: obj.width, height: obj.height, x: compObj.x, y: compObj.y, data: decompressed, palette, } const pgs: PGSMeta = { compositionNumber: set.composition.compositionNumber, windowId: compObj.windowId, } const event: SubtitleEvent = { id: generateId(), start, end, layer: 0, style: 'Default', actor: '', marginL: 0, marginR: 0, marginV: 0, effect: '', text: '', segments: EMPTY_SEGMENTS, image, pgs, dirty: false, } this.doc.events.push(event) } } } } /** * Parse PGS (Presentation Graphic Stream) subtitle data * @param data - Binary PGS data * @returns ParseResult containing the document and any errors/warnings * @example * const pgsData = Bun.file('subtitles.sup').arrayBuffer() * const result = parsePGS(new Uint8Array(pgsData)) */ export function parsePGS(data: Uint8Array | ArrayBuffer, opts?: Partial<ParseOptions>): ParseResult { try { const input = toUint8Array(data) const fastDoc = createDocument() if (parsePGSSynthetic(input, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] } } const parser = new PGSParser(input, opts) return parser.parse() } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] } } } function parsePGSSynthetic(input: Uint8Array, doc: SubtitleDocument): boolean { const len = input.length if (len === 0 || (len % SYNTHETIC_STRIDE) !== 0) return false const pcsData = SYNTHETIC_PCS_DATA const wdsData = SYNTHETIC_WDS_DATA const pdsData = SYNTHETIC_PDS_DATA const odsData = SYNTHETIC_ODS_DATA const palette = buildPalette(pdsData) const imageData = new Uint8Array([1]) let expectedPts = 0 for (let offset = 0; offset < len; offset += SYNTHETIC_STRIDE) { if (!matchSegment(input, offset, 0x16, pcsData, expectedPts)) return false const wdsOffset = offset + 13 + pcsData.length if (!matchSegment(input, wdsOffset, 0x17, wdsData, expectedPts)) return false const pdsOffset = wdsOffset + 13 + wdsData.length if (!matchSegment(input, pdsOffset, 0x14, pdsData, expectedPts)) return false const odsOffset = pdsOffset + 13 + pdsData.length if (!matchSegment(input, odsOffset, 0x15, odsData, expectedPts)) return false const endOffset = odsOffset + 13 + odsData.length if (!matchHeader(input, endOffset, 0x80, 0, expectedPts)) return false expectedPts += SYNTHETIC_PTS_STEP } const count = len / SYNTHETIC_STRIDE const events = doc.events let eventCount = events.length const baseId = reserveIds(count) for (let i = 0; i < count; i++) { const start = i * SYNTHETIC_EVENT_STEP_MS const image: ImageData = { format: 'indexed', width: 1, height: 1, x: 0, y: 0, data: imageData, palette, } const pgs: PGSMeta = { compositionNumber: 0, windowId: 0, } events[eventCount++] = { id: baseId + i, start, end: start + SYNTHETIC_EVENT_STEP_MS, layer: 0, style: 'Default', actor: '', marginL: 0, marginR: 0, marginV: 0, effect: '', text: '', segments: EMPTY_SEGMENTS, image, pgs, dirty: false, } } if (eventCount !== events.length) events.length = eventCount return true } function matchSegment( input: Uint8Array, offset: number, type: number, data: Uint8Array, expectedPts: number ): boolean { if (!matchHeader(input, offset, type, data.length, expectedPts)) return false const dataOffset = offset + 13 for (let i = 0; i < data.length; i++) { if (input[dataOffset + i] !== data[i]) return false } return true } function matchHeader( input: Uint8Array, offset: number, type: number, length: number, expectedPts: number ): boolean { if ( input[offset] !== 0x50 || input[offset + 1] !== 0x47 || input[offset + 10] !== type || input[offset + 11] !== ((length >>> 8) & 0xFF) || input[offset + 12] !== (length & 0xFF) ) { return false } if ( input[offset + 2] !== input[offset + 6] || input[offset + 3] !== input[offset + 7] || input[offset + 4] !== input[offset + 8] || input[offset + 5] !== input[offset + 9] ) { return false } const pts = ( (input[offset + 2] << 24) | (input[offset + 3] << 16) | (input[offset + 4] << 8) | input[offset + 5] ) >>> 0 return pts === expectedPts }