UNPKG

subforge

Version:

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

253 lines (205 loc) 7.78 kB
import type { SubtitleDocument, SubtitleEvent, ImageEffect } from '../../../core/types.ts' const SYNC_BYTE = 0x0F const PAGE_COMPOSITION = 0x10 const REGION_COMPOSITION = 0x11 const CLUT_DEFINITION = 0x12 const OBJECT_DATA = 0x13 const END_OF_DISPLAY_SET = 0x80 class DVBSerializer { private segments: Uint8Array[] = [] private objectIdCounter = 0 private regionIdCounter = 0 serialize(doc: SubtitleDocument): Uint8Array { for (const event of doc.events) { this.serializeEvent(event) } // Combine all segments const totalLength = this.segments.reduce((sum, seg) => sum + seg.length, 0) const result = new Uint8Array(totalLength) let offset = 0 for (const segment of this.segments) { result.set(segment, offset) offset += segment.length } return result } private serializeEvent(event: SubtitleEvent): void { // Extract image effects from segments const imageEffects: ImageEffect[] = [] if (event.image) { imageEffects.push({ type: 'image', params: event.image }) } for (const segment of event.segments) { for (const effect of segment.effects) { if (effect.type === 'image') { imageEffects.push(effect as ImageEffect) } } } if (imageEffects.length === 0) return const pageId = 0 const timeout = Math.min(255, Math.floor((event.end - event.start) / 1000)) // Write page composition segment this.writePageComposition(pageId, timeout, imageEffects.length) // Write CLUT for each image that has a palette const clutWritten = new Set<string>() for (let i = 0; i < imageEffects.length; i++) { const image = imageEffects[i] if (image.params.palette && image.params.palette.length > 0) { const paletteKey = image.params.palette.join(',') if (!clutWritten.has(paletteKey)) { this.writeCLUT(i, image.params.palette) clutWritten.add(paletteKey) } } } // Write region and object for each image for (let i = 0; i < imageEffects.length; i++) { const image = imageEffects[i] const regionId = this.regionIdCounter++ const objectId = this.objectIdCounter++ this.writeRegionComposition(regionId, pageId, image.params.x || 0, image.params.y || 0, image.params.width, image.params.height, i) this.writeObjectData(objectId, pageId, image.params.data, image.params.width, image.params.height) } // Write end of display set this.writeEndOfDisplaySet(pageId) } private writePageComposition(pageId: number, timeout: number, regionCount: number): void { const data = new Uint8Array(2 + regionCount * 6) const view = new DataView(data.buffer) view.setUint8(0, timeout) view.setUint8(1, 0x00) // version 0, page state 0 // For simplicity, we don't write region references in page composition // Real implementation would include region positioning here this.writeSegment(PAGE_COMPOSITION, pageId, data) } private writeRegionComposition(regionId: number, pageId: number, x: number, y: number, width: number, height: number, clutId: number): void { const data = new Uint8Array(16) const view = new DataView(data.buffer) view.setUint8(0, regionId) view.setUint8(1, 0x00) // version 0 view.setUint8(2, 0x00) // fill flag view.setUint16(3, x, false) view.setUint16(5, y, false) view.setUint16(7, width, false) view.setUint16(9, height, false) view.setUint8(11, clutId) view.setUint8(12, 0x08) // 8-bit depth view.setUint8(13, 0x00) // object count view.setUint16(14, 0, false) // object ID this.writeSegment(REGION_COMPOSITION, pageId, data) } private writeCLUT(clutId: number, palette: number[]): void { const data = new Uint8Array(2 + palette.length * 6) const view = new DataView(data.buffer) view.setUint8(0, clutId) view.setUint8(1, 0x00) // version 0 let offset = 2 for (let i = 0; i < palette.length; i++) { const rgba = palette[i] const r = (rgba >> 24) & 0xFF const g = (rgba >> 16) & 0xFF const b = (rgba >> 8) & 0xFF const a = rgba & 0xFF const { y, cr, cb } = this.rgbaToYcrcb(r, g, b) const t = 255 - a // Convert alpha to transparency view.setUint8(offset, i) // entry ID view.setUint8(offset + 1, 0x01) // full range flag view.setUint8(offset + 2, y) view.setUint8(offset + 3, cr) view.setUint8(offset + 4, cb) view.setUint8(offset + 5, t) offset += 6 } this.writeSegment(CLUT_DEFINITION, 0, data) } private writeObjectData(objectId: number, pageId: number, pixelData: Uint8Array, width: number, height: number): void { // Encode with simple RLE const encoded = this.encodeRLE(pixelData) const data = new Uint8Array(7 + encoded.length) const view = new DataView(data.buffer) view.setUint16(0, objectId, false) view.setUint8(2, 0x00) // version 0, coding method 0 view.setUint16(3, encoded.length, false) // top field length view.setUint16(5, 0, false) // bottom field length data.set(encoded, 7) this.writeSegment(OBJECT_DATA, pageId, data) } private writeEndOfDisplaySet(pageId: number): void { this.writeSegment(END_OF_DISPLAY_SET, pageId, new Uint8Array(0)) } private writeSegment(type: number, pageId: number, data: Uint8Array): void { const segment = new Uint8Array(6 + data.length) const view = new DataView(segment.buffer) view.setUint8(0, SYNC_BYTE) view.setUint8(1, type) view.setUint16(2, pageId, false) view.setUint16(4, data.length, false) segment.set(data, 6) this.segments.push(segment) } private encodeRLE(data: Uint8Array): Uint8Array { const encoded: number[] = [] let i = 0 while (i < data.length) { const pixel = data[i] let runLength = 1 // Count consecutive pixels while (i + runLength < data.length && data[i + runLength] === pixel && runLength < 63) { runLength++ } if (runLength === 1) { // Single pixel if (pixel !== 0) { encoded.push(pixel) } else { encoded.push(0, 0x01) // Run of 1 color 0 } } else if (pixel === 0) { // Run of color 0 if (runLength <= 63) { encoded.push(0, runLength) } else { encoded.push(0, 0x80 | ((runLength >> 8) & 0x3F), runLength & 0xFF) } } else { // Run of color if (runLength <= 63) { encoded.push(0, 0x40 | runLength, pixel) } else { encoded.push(0, 0xC0 | ((runLength >> 8) & 0x3F), runLength & 0xFF, pixel) } } i += runLength } // End of line encoded.push(0, 0) return new Uint8Array(encoded) } private rgbaToYcrcb(r: number, g: number, b: number): { y: number, cr: number, cb: number } { // ITU-R BT.601 conversion const y = Math.round(0.299 * r + 0.587 * g + 0.114 * b) const cr = Math.round(128 + 0.5 * r - 0.418688 * g - 0.081312 * b) const cb = Math.round(128 - 0.168736 * r - 0.331264 * g + 0.5 * b) return { y: Math.max(0, Math.min(255, y)), cr: Math.max(0, Math.min(255, cr)), cb: Math.max(0, Math.min(255, cb)) } } } /** * Convert subtitle document to DVB (Digital Video Broadcasting) format * @param doc - Subtitle document to serialize * @returns Binary DVB subtitle data * @example * const dvbData = toDVB(document) * Bun.write('output.dvb', dvbData) */ export function toDVB(doc: SubtitleDocument): Uint8Array { const serializer = new DVBSerializer() return serializer.serialize(doc) }