subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
253 lines (205 loc) • 7.78 kB
text/typescript
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)
}