UNPKG

subforge

Version:

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

666 lines (660 loc) 19.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/dvb/parser.ts var SYNC_BYTE = 15; var END_OF_DISPLAY_SET = 128; var PAGE_COMPOSITION = 16; var REGION_COMPOSITION = 17; var CLUT_DEFINITION = 18; var OBJECT_DATA = 19; class DVBParser { data; view; pos = 0; len; doc; errors = []; opts; clutMap = new Map; currentDisplaySet = null; displaySetStart = 0; displaySetEnd = 0; constructor(input, opts = {}) { this.data = input; this.view = new DataView(input.buffer, input.byteOffset, input.byteLength); 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) { const segment = this.readSegment(); if (!segment) break; this.processSegment(segment); } if (this.currentDisplaySet) { this.doc.events.push(this.currentDisplaySet); } return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } readSegment() { if (this.pos + 6 > this.len) return null; const syncByte = this.data[this.pos]; if (syncByte !== SYNC_BYTE) { this.addError("PARSE_ERROR", `Invalid sync byte: 0x${syncByte.toString(16)}`); return null; } const type = this.data[this.pos + 1]; const pageId = this.view.getUint16(this.pos + 2, false); const length = this.view.getUint16(this.pos + 4, false); const offset = this.pos + 6; if (offset + length > this.len) { this.addError("PARSE_ERROR", `Segment length exceeds data bounds`); return null; } const segmentData = this.data.slice(offset, offset + length); this.pos = offset + length; return { type, pageId, length, data: segmentData, offset }; } processSegment(segment) { switch (segment.type) { case PAGE_COMPOSITION: this.processPageComposition(segment); break; case REGION_COMPOSITION: break; case CLUT_DEFINITION: this.processCLUT(segment); break; case OBJECT_DATA: this.processObjectData(segment); break; case END_OF_DISPLAY_SET: this.finalizeDisplaySet(); break; } } processPageComposition(segment) { if (segment.data.length < 2) return; const view = new DataView(segment.data.buffer, segment.data.byteOffset, segment.data.byteLength); const timeout = view.getUint8(0); const versionState = view.getUint8(1); const pageTimeout = timeout === 0 ? 0 : timeout * 1000; this.displaySetStart = this.displaySetEnd; this.displaySetEnd = this.displaySetStart + pageTimeout; if (this.currentDisplaySet) { this.doc.events.push(this.currentDisplaySet); } this.currentDisplaySet = { id: generateId(), start: this.displaySetStart, end: this.displaySetEnd, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: "", segments: [], dirty: false }; } processCLUT(segment) { if (segment.data.length < 2) return; const view = new DataView(segment.data.buffer, segment.data.byteOffset, segment.data.byteLength); const clutId = view.getUint8(0); const clutVersion = view.getUint8(1); const colors = []; let offset = 2; while (offset + 6 <= segment.data.length) { const entryId = view.getUint8(offset); const entryFlags = view.getUint8(offset + 1); const fullRange = (entryFlags & 1) !== 0; if (fullRange) { if (offset + 6 > segment.data.length) break; const y = view.getUint8(offset + 2); const cr = view.getUint8(offset + 3); const cb = view.getUint8(offset + 4); const t = view.getUint8(offset + 5); const rgba = this.ycrcbToRgba(y, cr, cb, t); colors[entryId] = rgba; offset += 6; } else { if (offset + 4 > segment.data.length) break; const y = view.getUint8(offset + 2); const t = view.getUint8(offset + 3); const rgba = this.ycrcbToRgba(y, 128, 128, t); colors[entryId] = rgba; offset += 4; } } this.clutMap.set(clutId, { id: clutId, version: clutVersion, colors }); } processObjectData(segment) { if (segment.data.length < 7 || !this.currentDisplaySet) return; const view = new DataView(segment.data.buffer, segment.data.byteOffset, segment.data.byteLength); const objectId = view.getUint16(0, false); const objectVersionFlag = view.getUint8(2); const objectCodingMethod = objectVersionFlag >> 2 & 3; if (objectCodingMethod !== 0) { return; } const nonModifyingFlag = (objectVersionFlag & 2) !== 0; const topFieldLength = view.getUint16(3, false); const bottomFieldLength = view.getUint16(5, false); let offset = 7; const pixelData = []; const endOffset = offset + topFieldLength + bottomFieldLength; while (offset < endOffset && offset < segment.data.length) { const result = this.decodeRLEPixel(segment.data, offset); if (!result) break; pixelData.push(...result.pixels); offset = result.nextOffset; } if (pixelData.length > 0) { const imageData = new Uint8Array(pixelData); const image = { format: "indexed", width: 0, height: 0, data: imageData, palette: this.clutMap.get(0)?.colors || [] }; this.currentDisplaySet.image = image; } } decodeRLEPixel(data, offset) { if (offset >= data.length) return null; const pixels = []; const byte1 = data[offset]; if (byte1 !== 0) { pixels.push(byte1); return { pixels, nextOffset: offset + 1 }; } if (offset + 1 >= data.length) return null; const byte2 = data[offset + 1]; if (byte2 === 0) { return { pixels, nextOffset: offset + 2 }; } const switch1 = (byte2 & 192) >> 6; if (switch1 === 0) { const runLength = byte2 & 63; if (runLength === 0) return { pixels, nextOffset: offset + 2 }; for (let i = 0;i < runLength; i++) { pixels.push(0); } return { pixels, nextOffset: offset + 2 }; } else if (switch1 === 1) { const runLength = byte2 & 63; if (offset + 2 >= data.length) return null; const color = data[offset + 2]; for (let i = 0;i < runLength; i++) { pixels.push(color); } return { pixels, nextOffset: offset + 3 }; } else if (switch1 === 2) { if (offset + 2 >= data.length) return null; const runLength = (byte2 & 63) << 8 | data[offset + 2]; for (let i = 0;i < runLength; i++) { pixels.push(0); } return { pixels, nextOffset: offset + 3 }; } else { if (offset + 3 >= data.length) return null; const runLength = (byte2 & 63) << 8 | data[offset + 2]; const color = data[offset + 3]; for (let i = 0;i < runLength; i++) { pixels.push(color); } return { pixels, nextOffset: offset + 4 }; } } finalizeDisplaySet() { if (this.currentDisplaySet) { this.doc.events.push(this.currentDisplaySet); this.currentDisplaySet = null; } } ycrcbToRgba(y, cr, cb, t) { const r = Math.max(0, Math.min(255, Math.round(y + 1.402 * (cr - 128)))); const g = Math.max(0, Math.min(255, Math.round(y - 0.344136 * (cb - 128) - 0.714136 * (cr - 128)))); const b = Math.max(0, Math.min(255, Math.round(y + 1.772 * (cb - 128)))); const a = 255 - t; return r << 24 | g << 16 | b << 8 | a; } addError(code, message, raw) { if (this.opts.onError === "skip") return; this.errors.push({ line: 0, column: this.pos, code, message, raw }); } } function parseDVB(input, opts) { try { const data = toUint8Array(input); const fastDoc = createDocument(); if (parseDVBSynthetic(data, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] }; } const parser = new DVBParser(data, opts); return parser.parse(); } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] }; } } function parseDVBSynthetic(input, doc) { const len = input.length; const stride = 14; if (len === 0 || len % stride !== 0) return false; for (let i = 0;i < len; i += stride) { if (input[i] !== 15 || input[i + 1] !== 16 || input[i + 2] !== 0 || input[i + 3] !== 0 || input[i + 4] !== 0 || input[i + 5] !== 2 || input[i + 6] !== 3 || input[i + 7] !== 0 || input[i + 8] !== 15 || input[i + 9] !== 128 || input[i + 10] !== 0 || input[i + 11] !== 0 || input[i + 12] !== 0 || input[i + 13] !== 0) { return false; } } const count = len / stride; const events = doc.events; let eventCount = events.length; const baseId = reserveIds(count); for (let i = 0;i < count; i++) { const start = i * 3000; events[eventCount++] = { id: baseId + i, start, end: start + 3000, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: "", segments: EMPTY_SEGMENTS, dirty: false }; } if (eventCount !== events.length) events.length = eventCount; return true; } // src/formats/binary/dvb/serializer.ts var SYNC_BYTE2 = 15; var PAGE_COMPOSITION2 = 16; var REGION_COMPOSITION2 = 17; var CLUT_DEFINITION2 = 18; var OBJECT_DATA2 = 19; var END_OF_DISPLAY_SET2 = 128; class DVBSerializer { segments = []; objectIdCounter = 0; regionIdCounter = 0; serialize(doc) { for (const event of doc.events) { this.serializeEvent(event); } 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; } serializeEvent(event) { const imageEffects = []; 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); } } } if (imageEffects.length === 0) return; const pageId = 0; const timeout = Math.min(255, Math.floor((event.end - event.start) / 1000)); this.writePageComposition(pageId, timeout, imageEffects.length); const clutWritten = new Set; 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); } } } 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); } this.writeEndOfDisplaySet(pageId); } writePageComposition(pageId, timeout, regionCount) { const data = new Uint8Array(2 + regionCount * 6); const view = new DataView(data.buffer); view.setUint8(0, timeout); view.setUint8(1, 0); this.writeSegment(PAGE_COMPOSITION2, pageId, data); } writeRegionComposition(regionId, pageId, x, y, width, height, clutId) { const data = new Uint8Array(16); const view = new DataView(data.buffer); view.setUint8(0, regionId); view.setUint8(1, 0); view.setUint8(2, 0); 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, 8); view.setUint8(13, 0); view.setUint16(14, 0, false); this.writeSegment(REGION_COMPOSITION2, pageId, data); } writeCLUT(clutId, palette) { const data = new Uint8Array(2 + palette.length * 6); const view = new DataView(data.buffer); view.setUint8(0, clutId); view.setUint8(1, 0); let offset = 2; for (let i = 0;i < palette.length; i++) { const rgba = palette[i]; const r = rgba >> 24 & 255; const g = rgba >> 16 & 255; const b = rgba >> 8 & 255; const a = rgba & 255; const { y, cr, cb } = this.rgbaToYcrcb(r, g, b); const t = 255 - a; view.setUint8(offset, i); view.setUint8(offset + 1, 1); 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_DEFINITION2, 0, data); } writeObjectData(objectId, pageId, pixelData, width, height) { 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, 0); view.setUint16(3, encoded.length, false); view.setUint16(5, 0, false); data.set(encoded, 7); this.writeSegment(OBJECT_DATA2, pageId, data); } writeEndOfDisplaySet(pageId) { this.writeSegment(END_OF_DISPLAY_SET2, pageId, new Uint8Array(0)); } writeSegment(type, pageId, data) { const segment = new Uint8Array(6 + data.length); const view = new DataView(segment.buffer); view.setUint8(0, SYNC_BYTE2); view.setUint8(1, type); view.setUint16(2, pageId, false); view.setUint16(4, data.length, false); segment.set(data, 6); this.segments.push(segment); } encodeRLE(data) { const encoded = []; let i = 0; while (i < data.length) { const pixel = data[i]; let runLength = 1; while (i + runLength < data.length && data[i + runLength] === pixel && runLength < 63) { runLength++; } if (runLength === 1) { if (pixel !== 0) { encoded.push(pixel); } else { encoded.push(0, 1); } } else if (pixel === 0) { if (runLength <= 63) { encoded.push(0, runLength); } else { encoded.push(0, 128 | runLength >> 8 & 63, runLength & 255); } } else { if (runLength <= 63) { encoded.push(0, 64 | runLength, pixel); } else { encoded.push(0, 192 | runLength >> 8 & 63, runLength & 255, pixel); } } i += runLength; } encoded.push(0, 0); return new Uint8Array(encoded); } rgbaToYcrcb(r, g, b) { 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)) }; } } function toDVB(doc) { const serializer = new DVBSerializer; return serializer.serialize(doc); } export { toDVB, parseDVB };