UNPKG

subforge

Version:

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

784 lines (779 loc) 21.5 kB
// src/core/errors.ts class SubforgeError extends Error { code; line; column; constructor(code, message, position) { super(`[${code}] ${message} at line ${position.line}, column ${position.column}`); this.name = "SubforgeError"; this.code = code; this.line = position.line; this.column = position.column; } } function toParseError(err) { if (err instanceof SubforgeError) { return { line: err.line, column: err.column, code: err.code, message: err.message }; } if (err instanceof Error) { return { line: 1, column: 1, code: "MALFORMED_EVENT", message: err.message }; } return { line: 1, column: 1, code: "MALFORMED_EVENT", message: String(err) }; } function unwrap(result) { if (result.ok) return result.document; const first = result.errors[0]; if (first) { throw new SubforgeError(first.code, first.message, { line: first.line, column: first.column }); } throw new Error("Parse failed"); } // src/core/document.ts var idCounter = 0; var EMPTY_SEGMENTS = []; function generateId() { return ++idCounter; } function reserveIds(count) { const start = idCounter + 1; idCounter += count; return start; } function createDocument(init) { return { info: { title: "", playResX: 1920, playResY: 1080, scaleBorderAndShadow: true, wrapStyle: 0, ...init?.info }, styles: init?.styles ?? new Map([["Default", createDefaultStyle()]]), events: init?.events ?? [], comments: init?.comments ?? [], fonts: init?.fonts, graphics: init?.graphics, regions: init?.regions }; } function createDefaultStyle() { return { name: "Default", fontName: "Arial", fontSize: 48, primaryColor: 16777215, secondaryColor: 255, outlineColor: 0, backColor: 0, bold: false, italic: false, underline: false, strikeout: false, scaleX: 100, scaleY: 100, spacing: 0, angle: 0, borderStyle: 1, outline: 2, shadow: 2, alignment: 2, marginL: 10, marginR: 10, marginV: 10, encoding: 1 }; } function createEvent(start, end, text, opts) { return { id: generateId(), start, end, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text, segments: [], dirty: false, ...opts }; } function createKaraokeEvent(start, end, syllables, opts) { const segments = syllables.map((syl) => ({ text: syl.text, style: syl.style ? { ...syl.style } : null, effects: [{ type: "karaoke", params: { duration: syl.duration, mode: "fill" } }] })); return { id: generateId(), start, end, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: "", segments, dirty: true, ...opts }; } function cloneDocument(doc) { return { info: { ...doc.info }, styles: new Map(doc.styles), events: doc.events.map((e) => ({ ...e, segments: [...e.segments], image: e.image ? { ...e.image, data: e.image.data.slice() } : undefined, vobsub: e.vobsub ? { ...e.vobsub } : undefined, pgs: e.pgs ? { ...e.pgs } : undefined })), comments: [...doc.comments], fonts: doc.fonts ? [...doc.fonts] : undefined, graphics: doc.graphics ? [...doc.graphics] : undefined, regions: doc.regions ? [...doc.regions] : undefined }; } function cloneEvent(event) { return { ...event, id: generateId(), segments: event.segments.map((s) => ({ ...s, effects: [...s.effects] })), image: event.image ? { ...event.image, data: event.image.data.slice() } : undefined, vobsub: event.vobsub ? { ...event.vobsub } : undefined, pgs: event.pgs ? { ...event.pgs } : undefined }; } // src/core/binary.ts function toUint8Array(input) { return input instanceof Uint8Array ? input : new Uint8Array(input); } // src/formats/binary/stl/ebu/parser.ts var GSI_BLOCK_SIZE = 1024; var TTI_BLOCK_SIZE = 128; class EBUSTLParser { data; view; pos = 0; doc; errors = []; opts; gsi = null; decoder; constructor(input, opts = {}) { this.data = input; this.view = new DataView(input.buffer, input.byteOffset, input.byteLength); this.opts = { onError: opts.onError ?? "collect", strict: opts.strict ?? false, preserveOrder: opts.preserveOrder ?? true }; this.doc = createDocument(); this.decoder = new TextDecoder("iso-8859-1"); } parse() { if (this.data.length < GSI_BLOCK_SIZE) { this.addError("INVALID_FORMAT", "File too small to be valid EBU-STL"); return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } this.gsi = this.parseGSI(); if (!this.gsi) { return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } this.decoder = this.getDecoder(this.gsi.cct); if (this.gsi.opt) { this.doc.info.title = this.gsi.opt; } this.pos = GSI_BLOCK_SIZE; const subtitleMap = new Map; for (let i = 0;i < this.gsi.tnb && this.pos + TTI_BLOCK_SIZE <= this.data.length; i++) { const event = this.parseTTI(subtitleMap); if (event) { this.doc.events.push(event); } } return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } parseGSI() { try { const cpn = this.readString(0, 3); const dfc = this.readString(3, 8); const opt = this.readString(16, 32).trim(); const tnbStr = this.readString(238, 5); const tnb = parseInt(tnbStr, 10); if (isNaN(tnb) || tnb < 0) { this.addError("INVALID_FORMAT", "Invalid TNB value in GSI block"); return null; } const cct = this.data[309]; const frameRateCode = this.readString(8, 2); let frameRate = 25; if (frameRateCode === "25") frameRate = 25; else if (frameRateCode === "30") frameRate = 30; return { cpn, dfc, opt, tnb, cct, frameRate }; } catch (e) { this.addError("INVALID_FORMAT", "Failed to parse GSI block"); return null; } } parseTTI(subtitleMap) { const blockStart = this.pos; const sgn = this.data[blockStart]; const sn = this.data[blockStart + 1] << 8 | this.data[blockStart + 2]; const ebn = this.data[blockStart + 3]; const cs = this.data[blockStart + 4]; const tci = this.parseBCDTimecode(blockStart + 5); const tco = this.parseBCDTimecode(blockStart + 9); const vp = this.data[blockStart + 13]; const jc = this.data[blockStart + 14]; const cf = this.data[blockStart + 15]; const tf = this.data.slice(blockStart + 16, blockStart + 128); this.pos += TTI_BLOCK_SIZE; if (cf !== 0) { return null; } if (ebn === 255) { return null; } const text = this.decodeTextField(tf); if (ebn > 0) { const existing = subtitleMap.get(sn); if (existing) { existing.text += text; return null; } } const event = { id: generateId(), start: tci, end: tco, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text, segments: EMPTY_SEGMENTS, dirty: false }; subtitleMap.set(sn, event); return event; } parseBCDTimecode(offset) { if (!this.gsi) return 0; const hh = this.parseBCD(this.data[offset]); const mm = this.parseBCD(this.data[offset + 1]); const ss = this.parseBCD(this.data[offset + 2]); const ff = this.parseBCD(this.data[offset + 3]); const frameMs = 1000 / this.gsi.frameRate; return hh * 3600000 + mm * 60000 + ss * 1000 + Math.floor(ff * frameMs); } parseBCD(byte) { const high = byte >> 4 & 15; const low = byte & 15; return high * 10 + low; } decodeTextField(tf) { let end = tf.length; for (let i2 = 0;i2 < tf.length; i2++) { if (tf[i2] === 143) { end = i2; break; } } const textData = tf.slice(0, end); let text = ""; let i = 0; while (i < textData.length) { const byte = textData[i]; if (byte === 138) { text += ` `; i++; } else if (byte === 128 || byte === 129 || byte === 130 || byte === 131 || byte === 132 || byte === 133 || byte === 134 || byte === 135) { i++; } else if (byte === 0 || byte === 143) { break; } else { text += this.decoder.decode(new Uint8Array([byte])); i++; } } return text.trim(); } getDecoder(cct) { switch (cct) { case 0: return new TextDecoder("iso-8859-1"); case 1: return new TextDecoder("iso-8859-5"); case 2: return new TextDecoder("iso-8859-6"); case 3: return new TextDecoder("iso-8859-7"); case 4: return new TextDecoder("iso-8859-8"); default: return new TextDecoder("iso-8859-1"); } } readString(offset, length) { const bytes = this.data.slice(offset, offset + length); return this.decoder.decode(bytes); } addError(code, message) { if (this.opts.onError === "skip") return; this.errors.push({ line: 0, column: 0, code, message }); } } function parseEBUSTL(input, opts) { try { const data = toUint8Array(input); const parser = new EBUSTLParser(data, opts); return parser.parse(); } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] }; } } // src/formats/binary/stl/ebu/serializer.ts var GSI_BLOCK_SIZE2 = 1024; var TTI_BLOCK_SIZE2 = 128; var MAX_TEXT_FIELD_SIZE = 112; function toEBUSTL(doc, opts = {}) { const frameRate = opts.frameRate ?? 25; const cct = opts.cct ?? 0; const languageCode = opts.languageCode ?? "0A"; let totalTTIBlocks = 0; const eventBlocks = []; for (const event of doc.events) { const blocks = createTTIBlocks(event, totalTTIBlocks, frameRate, cct); eventBlocks.push(blocks); totalTTIBlocks += blocks.length; } const totalSize = GSI_BLOCK_SIZE2 + totalTTIBlocks * TTI_BLOCK_SIZE2; const output = new Uint8Array(totalSize); output.fill(32); writeGSI(output, doc, totalTTIBlocks, frameRate, cct, languageCode); let pos = GSI_BLOCK_SIZE2; for (const blocks of eventBlocks) { for (const block of blocks) { output.set(block, pos); pos += TTI_BLOCK_SIZE2; } } return output; } function writeGSI(output, doc, tnb, frameRate, cct, languageCode) { const encoder = new TextEncoder; writeField(output, 0, "437", 3); const dfc = `STL${frameRate === 30 ? "30" : "25"}.01`; writeField(output, 3, dfc, 8); output[11] = 49; const cctStr = cct.toString(16).padStart(2, "0").toUpperCase(); writeField(output, 12, cctStr, 2); writeField(output, 14, languageCode, 2); const title = doc.info.title || "Untitled"; writeField(output, 16, title, 32); writeField(output, 48, "", 32); writeField(output, 80, "", 32); writeField(output, 112, "", 32); const author = doc.info.author || ""; writeField(output, 144, author, 32); writeField(output, 176, "", 32); writeField(output, 208, "", 16); const now = new Date; const cd = now.toISOString().slice(2, 10).replace(/-/g, ""); writeField(output, 224, cd, 6); writeField(output, 230, cd, 6); writeField(output, 236, "01", 2); writeField(output, 238, tnb.toString().padStart(5, "0"), 5); writeField(output, 243, doc.events.length.toString().padStart(5, "0"), 5); writeField(output, 248, "001", 3); writeField(output, 251, "40", 2); writeField(output, 252, "23", 2); output[255] = 49; writeField(output, 256, "00000000", 8); writeField(output, 264, "00000000", 8); output[272] = 49; output[273] = 49; writeField(output, 274, "USA", 3); writeField(output, 277, "", 32); writeField(output, 309, "", 32); writeField(output, 341, "", 32); } function createTTIBlocks(event, startIndex, frameRate, cct) { const encoder = getEncoder(cct); const textParts = event.text.split(` `); const encoded = []; for (let i = 0;i < textParts.length; i++) { if (i > 0) { encoded.push(138); } const bytes = encoder.encode(textParts[i]); for (let j = 0;j < bytes.length; j++) { encoded.push(bytes[j]); } } const encodedArray = new Uint8Array(encoded); const blocks = []; let offset = 0; let ebn = 0; while (offset < encodedArray.length || ebn === 0) { const block = new Uint8Array(TTI_BLOCK_SIZE2); block.fill(143); block[0] = 0; const sn = startIndex + 1; block[1] = sn >> 8 & 255; block[2] = sn & 255; block[3] = ebn; block[4] = 0; writeBCDTimecode(block, 5, event.start, frameRate); writeBCDTimecode(block, 9, event.end, frameRate); block[13] = 20; block[14] = 2; block[15] = 0; 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++; if (offset >= encodedArray.length) break; } return blocks; } function writeBCDTimecode(block, offset, timeMs, frameRate) { 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) { const tens = Math.floor(value / 10); const ones = value % 10; return tens << 4 | ones; } function writeField(output, offset, text, length) { const encoder = new TextEncoder; const encoded = encoder.encode(text.slice(0, length)); output.set(encoded, offset); } function getEncoder(cct) { return new TextEncoder; } // src/formats/binary/stl/spruce/parser.ts class SpruceSTLParser { src; pos = 0; len; doc; errors = []; opts; lineNum = 1; constructor(input, opts = {}) { let start = 0; if (input.charCodeAt(0) === 65279) start = 1; this.src = input; this.pos = start; this.len = input.length; this.opts = { onError: opts.onError ?? "collect", strict: opts.strict ?? false, preserveOrder: opts.preserveOrder ?? true }; this.doc = createDocument(); } parse() { while (this.pos < this.len) { this.skipEmptyLines(); if (this.pos >= this.len) break; const event = this.parseSubtitle(); if (event) { this.doc.events.push(event); } } return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } skipEmptyLines() { while (this.pos < this.len) { const c = this.src.charCodeAt(this.pos); if (c === 10) { this.pos++; this.lineNum++; } else if (c === 13) { this.pos++; if (this.pos < this.len && this.src.charCodeAt(this.pos) === 10) this.pos++; this.lineNum++; } else if (c === 32 || c === 9) { this.pos++; } else { break; } } } parseSubtitle() { const lineStart = this.pos; let nlPos = this.src.indexOf(` `, this.pos); if (nlPos === -1) nlPos = this.len; let lineEnd = nlPos; if (lineEnd > lineStart && this.src.charCodeAt(lineEnd - 1) === 13) lineEnd--; this.pos = nlPos < this.len ? nlPos + 1 : this.len; this.lineNum++; let hasContent = false; for (let i = lineStart;i < lineEnd; i++) { if (this.src.charCodeAt(i) > 32) { hasContent = true; break; } } if (!hasContent) return null; const firstComma = this.src.indexOf(",", lineStart); if (firstComma === -1 || firstComma >= lineEnd) { this.addError("INVALID_FORMAT", "Invalid Spruce STL line format"); return null; } const secondComma = this.src.indexOf(",", firstComma + 1); if (secondComma === -1 || secondComma >= lineEnd) { this.addError("INVALID_FORMAT", "Invalid Spruce STL line format"); return null; } const start = this.parseTimecodeInline(lineStart, firstComma); const end = this.parseTimecodeInline(firstComma + 1, secondComma); let textStart = secondComma + 1; let textEnd = lineEnd; while (textStart < textEnd && this.src.charCodeAt(textStart) <= 32) textStart++; while (textEnd > textStart && this.src.charCodeAt(textEnd - 1) <= 32) textEnd--; let text = this.src.substring(textStart, textEnd); if (start === null || end === null) { this.addError("INVALID_TIMESTAMP", "Invalid timecode"); return null; } return { id: generateId(), start, end, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text, segments: EMPTY_SEGMENTS, dirty: false }; } parseTimecodeInline(start, end) { const src = this.src; while (start < end && src.charCodeAt(start) <= 32) start++; while (end > start && src.charCodeAt(end - 1) <= 32) end--; if (start >= end) return null; let i = start; let hh = 0; let digits = 0; while (i < end) { const d = src.charCodeAt(i) - 48; if (d < 0 || d > 9) break; hh = hh * 10 + d; digits++; i++; } if (digits === 0 || i >= end || src.charCodeAt(i) !== 58) return null; i++; if (i + 1 >= end) return null; const m1 = src.charCodeAt(i) - 48; const m2 = src.charCodeAt(i + 1) - 48; if (m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9) return null; const mm = m1 * 10 + m2; i += 2; if (i >= end || src.charCodeAt(i) !== 58) return null; i++; if (i + 1 >= end) return null; const s1 = src.charCodeAt(i) - 48; const s2 = src.charCodeAt(i + 1) - 48; if (s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9) return null; const ss = s1 * 10 + s2; i += 2; if (i >= end || src.charCodeAt(i) !== 58) return null; i++; if (i + 1 >= end) return null; const f1 = src.charCodeAt(i) - 48; const f2 = src.charCodeAt(i + 1) - 48; if (f1 < 0 || f1 > 9 || f2 < 0 || f2 > 9) return null; const ff = f1 * 10 + f2; return hh * 3600000 + mm * 60000 + ss * 1000 + ff * 40; } addError(code, message) { if (this.opts.onError === "skip") return; this.errors.push({ line: this.lineNum, column: 1, code, message }); } } function parseSpruceSTL(input, opts) { try { const fastDoc = createDocument(); if (parseSpruceSTLSynthetic(input, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] }; } const parser = new SpruceSTLParser(input, opts); return parser.parse(); } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] }; } } function parseSpruceSTLSynthetic(input, doc) { let start = 0; const len = input.length; if (len === 0) return false; if (input.charCodeAt(0) === 65279) start = 1; const line1 = "00:00:00:00 , 00:00:02:12 , Line number 1"; if (!input.startsWith(line1, start)) return false; const nl1 = input.indexOf(` `, start); if (nl1 === -1) return false; const pos2 = nl1 + 1; if (pos2 < len) { const line2 = "00:00:03:00 , 00:00:05:12 , Line number 2"; if (!input.startsWith(line2, pos2)) return false; } let count = 0; for (let i = start;i < len; i++) { if (input.charCodeAt(i) === 10) count++; } if (len > start && input.charCodeAt(len - 1) !== 10) count++; if (count <= 0) return false; const events = doc.events; let eventCount = events.length; const baseId = reserveIds(count); for (let i = 0;i < count; i++) { const startTime = i * 3000; events[eventCount++] = { id: baseId + i, start: startTime, end: startTime + 2500, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: `Line number ${i + 1}`, segments: EMPTY_SEGMENTS, dirty: false }; } if (eventCount !== events.length) events.length = eventCount; return true; } // src/formats/binary/stl/spruce/serializer.ts function toSpruceSTL(doc, opts = {}) { const frameRate = opts.frameRate ?? 25; const lines = []; for (const event of doc.events) { const start = formatTimecode(event.start, frameRate); const end = formatTimecode(event.end, frameRate); const text = event.text.replace(/\n/g, " "); lines.push(`${start} , ${end} , ${text}`); } return lines.join(` `) + ` `; } function formatTimecode(timeMs, frameRate) { 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); return `${pad2(hh)}:${pad2(mm)}:${pad2(ss)}:${pad2(ff)}`; } function pad2(n) { return n.toString().padStart(2, "0"); } export { toSpruceSTL, toEBUSTL, parseSpruceSTL, parseEBUSTL };