UNPKG

subforge

Version:

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

291 lines (227 loc) 8.19 kB
import type { SubtitleDocument } from '../../../../core/types.ts' const GSI_BLOCK_SIZE = 1024 const TTI_BLOCK_SIZE = 128 const MAX_TEXT_FIELD_SIZE = 112 /** * Options for serializing to EBU-STL format */ export interface EBUSTLSerializeOptions { /** Frame rate for timecode conversion (default: 25) */ frameRate?: 25 | 30 /** Character Code Table for text encoding (default: 0x00 for Latin) */ cct?: number /** Two-character language code (default: '0A') */ languageCode?: string } /** * Serializes a subtitle document to EBU-STL binary format * * Creates a binary file following the EBU-STL specification with GSI and TTI blocks. * Automatically splits long subtitles across multiple TTI extension blocks if needed. * * @param doc - Subtitle document to serialize * @param opts - Serialization options for frame rate, encoding, and language * @returns Binary EBU-STL file data as Uint8Array * * @example * ```ts * const stlData = toEBUSTL(doc, { * frameRate: 25, * cct: 0x00, * languageCode: '0A' * }) * await Bun.write('output.stl', stlData) * ``` */ export function toEBUSTL(doc: SubtitleDocument, opts: EBUSTLSerializeOptions = {}): Uint8Array { const frameRate = opts.frameRate ?? 25 const cct = opts.cct ?? 0x00 // Default to Latin const languageCode = opts.languageCode ?? '0A' // Calculate total number of TTI blocks needed let totalTTIBlocks = 0 const eventBlocks: Uint8Array[][] = [] for (const event of doc.events) { const blocks = createTTIBlocks(event, totalTTIBlocks, frameRate, cct) eventBlocks.push(blocks) totalTTIBlocks += blocks.length } // Create output buffer const totalSize = GSI_BLOCK_SIZE + (totalTTIBlocks * TTI_BLOCK_SIZE) const output = new Uint8Array(totalSize) output.fill(0x20) // Fill with spaces (default for many fields) // Write GSI block writeGSI(output, doc, totalTTIBlocks, frameRate, cct, languageCode) // Write TTI blocks let pos = GSI_BLOCK_SIZE for (const blocks of eventBlocks) { for (const block of blocks) { output.set(block, pos) pos += TTI_BLOCK_SIZE } } return output } function writeGSI( output: Uint8Array, doc: SubtitleDocument, tnb: number, frameRate: number, cct: number, languageCode: string ): void { const encoder = new TextEncoder() // CPN (0-2) - Code Page Number writeField(output, 0, '437', 3) // DFC (3-10) - Disk Format Code const dfc = `STL${frameRate === 30 ? '30' : '25'}.01` writeField(output, 3, dfc, 8) // DSC (11) - Display Standard Code output[11] = 0x31 // '1' = Open subtitling // CCT (12-13) - Character Code Table const cctStr = cct.toString(16).padStart(2, '0').toUpperCase() writeField(output, 12, cctStr, 2) // LC (14-15) - Language Code writeField(output, 14, languageCode, 2) // OPT (16-47) - Original Programme Title const title = doc.info.title || 'Untitled' writeField(output, 16, title, 32) // OET (48-79) - Original Episode Title writeField(output, 48, '', 32) // TPT (80-111) - Translated Programme Title writeField(output, 80, '', 32) // TET (112-143) - Translated Episode Title writeField(output, 112, '', 32) // TN (144-175) - Translator's Name const author = doc.info.author || '' writeField(output, 144, author, 32) // TCD (176-207) - Translator's Contact Details writeField(output, 176, '', 32) // SLR (208-223) - Subtitle List Reference Code writeField(output, 208, '', 16) // CD (224-229) - Creation Date (YYMMDD) const now = new Date() const cd = now.toISOString().slice(2, 10).replace(/-/g, '') writeField(output, 224, cd, 6) // RD (230-235) - Revision Date (YYMMDD) writeField(output, 230, cd, 6) // RN (236-237) - Revision Number writeField(output, 236, '01', 2) // TNB (238-242) - Total Number of TTI blocks (5 digits) writeField(output, 238, tnb.toString().padStart(5, '0'), 5) // TNS (243-247) - Total Number of Subtitles writeField(output, 243, doc.events.length.toString().padStart(5, '0'), 5) // TNG (248-250) - Total Number of Subtitle Groups writeField(output, 248, '001', 3) // MNC (251-252) - Maximum Number of Displayable Characters writeField(output, 251, '40', 2) // MNR (253-254) - Maximum Number of Displayable Rows writeField(output, 252, '23', 2) // TCS (255) - Time Code: Status output[255] = 0x31 // '1' = Intended for use // TCP (256-263) - Time Code: Start-of-Programme writeField(output, 256, '00000000', 8) // TCF (264-271) - Time Code: First In-Cue writeField(output, 264, '00000000', 8) // TND (272) - Total Number of Disks output[272] = 0x31 // '1' // DSN (273) - Disk Sequence Number output[273] = 0x31 // '1' // CO (274-276) - Country of Origin writeField(output, 274, 'USA', 3) // PUB (277-308) - Publisher writeField(output, 277, '', 32) // EN (309-340) - Editor's Name writeField(output, 309, '', 32) // ECD (341-372) - Editor's Contact Details writeField(output, 341, '', 32) // Spare bytes (373-447) already filled with spaces // UDA (448-1023) - User-Defined Area // Left as spaces } function createTTIBlocks( event: SubtitleDocument['events'][0], startIndex: number, frameRate: number, cct: number ): Uint8Array[] { const encoder = getEncoder(cct) // Encode text with control codes const textParts = event.text.split('\n') const encoded: number[] = [] for (let i = 0; i < textParts.length; i++) { if (i > 0) { encoded.push(0x8a) // Line break control code } const bytes = encoder.encode(textParts[i]) for (let j = 0; j < bytes.length; j++) { encoded.push(bytes[j]) } } const encodedArray = new Uint8Array(encoded) // Split into blocks if needed (max 112 bytes per block) const blocks: Uint8Array[] = [] let offset = 0 let ebn = 0 while (offset < encodedArray.length || ebn === 0) { const block = new Uint8Array(TTI_BLOCK_SIZE) block.fill(0x8f) // Fill with end marker // SGN (0) - Subtitle Group Number block[0] = 0 // SN (1-2) - Subtitle Number (big-endian) const sn = startIndex + 1 block[1] = (sn >> 8) & 0xff block[2] = sn & 0xff // EBN (3) - Extension Block Number block[3] = ebn // CS (4) - Cumulative Status block[4] = 0x00 // Not cumulative // TCI (5-8) - Time Code In writeBCDTimecode(block, 5, event.start, frameRate) // TCO (9-12) - Time Code Out writeBCDTimecode(block, 9, event.end, frameRate) // VP (13) - Vertical Position block[13] = 0x14 // Row 20 (near bottom) // JC (14) - Justification Code block[14] = 0x02 // Centered // CF (15) - Comment Flag block[15] = 0x00 // Not a comment // TF (16-127) - Text Field const chunkSize = Math.min(MAX_TEXT_FIELD_SIZE, encodedArray.length - offset) if (chunkSize > 0) { block.set(encodedArray.slice(offset, offset + chunkSize), 16) offset += chunkSize } blocks.push(block) ebn++ // Only create extension blocks if there's more text if (offset >= encodedArray.length) break } return blocks } function writeBCDTimecode(block: Uint8Array, offset: number, timeMs: number, frameRate: number): void { const totalSeconds = Math.floor(timeMs / 1000) const hh = Math.floor(totalSeconds / 3600) const mm = Math.floor((totalSeconds % 3600) / 60) const ss = totalSeconds % 60 const ff = Math.floor(((timeMs % 1000) / 1000) * frameRate) block[offset] = toBCD(hh) block[offset + 1] = toBCD(mm) block[offset + 2] = toBCD(ss) block[offset + 3] = toBCD(ff) } function toBCD(value: number): number { const tens = Math.floor(value / 10) const ones = value % 10 return (tens << 4) | ones } function writeField(output: Uint8Array, offset: number, text: string, length: number): void { const encoder = new TextEncoder() const encoded = encoder.encode(text.slice(0, length)) output.set(encoded, offset) } function getEncoder(cct: number): TextEncoder { // Note: TextEncoder only supports UTF-8, but for basic Latin characters // this works fine. For proper support of other character sets, would need // a more sophisticated encoding library return new TextEncoder() }