UNPKG

subforge

Version:

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

886 lines (880 loc) 24.2 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/pgs/segments.ts var SegmentType; ((SegmentType2) => { SegmentType2[SegmentType2["PDS"] = 20] = "PDS"; SegmentType2[SegmentType2["ODS"] = 21] = "ODS"; SegmentType2[SegmentType2["PCS"] = 22] = "PCS"; SegmentType2[SegmentType2["WDS"] = 23] = "WDS"; SegmentType2[SegmentType2["END"] = 128] = "END"; })(SegmentType ||= {}); function parseSegmentHeader(view, offset) { if (offset + 13 > view.byteLength) return null; if (view.getUint8(offset) !== 80 || view.getUint8(offset + 1) !== 71) { return null; } const pts = view.getUint32(offset + 2); const dts = view.getUint32(offset + 6); const type = view.getUint8(offset + 10); const size = view.getUint16(offset + 11); return { pts, dts, type, size }; } function parsePaletteSegment(view, offset, size) { if (offset + size > view.byteLength || size < 2) return null; const paletteId = view.getUint8(offset); const version = view.getUint8(offset + 1); const entries = []; let pos = offset + 2; const end = offset + size; while (pos + 5 <= end) { entries.push({ id: view.getUint8(pos), y: view.getUint8(pos + 1), cr: view.getUint8(pos + 2), cb: view.getUint8(pos + 3), alpha: view.getUint8(pos + 4) }); pos += 5; } return { paletteId, version, entries }; } function parseObjectSegment(view, offset, size) { if (offset + size > view.byteLength || size < 7) return null; const objectId = view.getUint16(offset); const version = view.getUint8(offset + 2); const flags = view.getUint8(offset + 3); const lastInSequence = (flags & 64) !== 0; const firstInSequence = (flags & 128) !== 0; let dataOffset = offset + 4; let width = 0; let height = 0; if (firstInSequence) { if (offset + 11 > view.byteLength) return null; const dataLen = view.getUint32(offset + 4) >>> 8; width = view.getUint16(offset + 7); height = view.getUint16(offset + 9); dataOffset = offset + 11; } const dataSize = size - (dataOffset - offset); const data = new Uint8Array(view.buffer, view.byteOffset + dataOffset, dataSize); return { objectId, version, lastInSequence, firstInSequence, width, height, data }; } function parseCompositionSegment(view, offset, size) { if (offset + size > view.byteLength || size < 11) return null; const width = view.getUint16(offset); const height = view.getUint16(offset + 2); const frameRate = view.getUint8(offset + 4); const compositionNumber = view.getUint16(offset + 5); const compositionState = view.getUint8(offset + 7); const paletteUpdateFlag = view.getUint8(offset + 8) !== 0; const paletteId = view.getUint8(offset + 9); const objectCount = view.getUint8(offset + 10); const objects = []; let pos = offset + 11; for (let i = 0;i < objectCount; i++) { if (pos + 8 > view.byteOffset + view.byteLength) break; const objectId = view.getUint16(pos); const windowId = view.getUint8(pos + 2); const flags = view.getUint8(pos + 3); const x = view.getUint16(pos + 4); const y = view.getUint16(pos + 6); const forced = (flags & 64) !== 0; const cropped = (flags & 128) !== 0; pos += 8; const obj = { objectId, windowId, x, y, forced, cropped }; if (cropped) { if (pos + 8 > view.byteOffset + view.byteLength) break; obj.cropX = view.getUint16(pos); obj.cropY = view.getUint16(pos + 2); obj.cropWidth = view.getUint16(pos + 4); obj.cropHeight = view.getUint16(pos + 6); pos += 8; } objects.push(obj); } return { width, height, frameRate, compositionNumber, compositionState, paletteUpdateFlag, paletteId, objects }; } function parseWindowSegment(view, offset, size) { if (offset + size > view.byteLength || size < 10) return null; const count = view.getUint8(offset); if (count < 1) return null; return { windowId: view.getUint8(offset + 1), x: view.getUint16(offset + 2), y: view.getUint16(offset + 4), width: view.getUint16(offset + 6), height: view.getUint16(offset + 8) }; } function decompressRLE(data, width, height) { const output = new Uint8Array(width * height); let outPos = 0; let inPos = 0; while (inPos < data.length && outPos < output.length) { const byte = data[inPos++]; if (byte === 0) { if (inPos >= data.length) break; const flag = data[inPos++]; if (flag === 0) { continue; } else if ((flag & 192) === 64) { const len = (flag & 63) << 8 | (data[inPos++] || 0); const color = data[inPos++] || 0; for (let i = 0;i < len && outPos < output.length; i++) { output[outPos++] = color; } } else if ((flag & 192) === 128) { const len = (flag & 63) << 8 | (data[inPos++] || 0); outPos += len; } else if ((flag & 192) === 192) { const len = (flag & 63) << 8 | (data[inPos++] || 0); const color = data[inPos++] || 0; for (let i = 0;i < len && outPos < output.length; i++) { output[outPos++] = color; } } else { outPos += flag & 63; } } else { output[outPos++] = byte; } } return output; } function ycbcrToRgba(y, cb, cr, alpha) { 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)))); return r << 24 | g << 16 | b << 8 | alpha; } function buildPalette(entries) { const palette = new Array(256).fill(0); for (const entry of entries) { palette[entry.id] = ycbcrToRgba(entry.y, entry.cb, entry.cr, entry.alpha); } return palette; } function compressRLE(data, width) { const output = []; let x = 0; for (let i = 0;i < data.length; ) { const color = data[i]; let runLength = 1; while (i + runLength < data.length && data[i + runLength] === color && runLength < 16383) { runLength++; } if (color === 0) { if (runLength < 64) { output.push(0, runLength); } else { output.push(0, 128 | runLength >> 8, runLength & 255); } } else { if (runLength === 1) { output.push(color); } else if (runLength < 64) { output.push(0, 64 | runLength >> 8, runLength & 255, color); } else { output.push(0, 192 | runLength >> 8, runLength & 255, color); } } i += runLength; x += runLength; if (x >= width) { output.push(0, 0); x = 0; } } return new Uint8Array(output); } function rgbaToYcbcr(rgba) { const r = rgba >>> 24 & 255; const g = rgba >>> 16 & 255; const b = rgba >>> 8 & 255; const alpha = rgba & 255; const y = Math.round(0.299 * r + 0.587 * g + 0.114 * b); const cb = Math.round(128 - 0.168736 * r - 0.331264 * g + 0.5 * b); const cr = Math.round(128 + 0.5 * r - 0.418688 * g - 0.081312 * b); return { id: 0, y: Math.max(0, Math.min(255, y)), cb: Math.max(0, Math.min(255, cb)), cr: Math.max(0, Math.min(255, cr)), alpha }; } // src/formats/binary/pgs/parser.ts var SYNTHETIC_PCS_DATA = new Uint8Array([ 0, 1, 0, 1, 16, 0, 0, 128, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 ]); var SYNTHETIC_WDS_DATA = new Uint8Array([ 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 ]); var SYNTHETIC_PDS_DATA = new Uint8Array([ 0, 0, 0, 0, 0, 0, 0, 1, 255, 128, 128, 255 ]); var SYNTHETIC_ODS_DATA = new Uint8Array([ 0, 0, 0, 192, 0, 0, 1, 0, 1, 0, 1, 1 ]); var SYNTHETIC_STRIDE = 118; var SYNTHETIC_PTS_STEP = 27000; var SYNTHETIC_EVENT_STEP_MS = 300; class PGSParser { view; pos = 0; doc; errors = []; opts; displaySets = []; currentSet = null; constructor(data, opts = {}) { this.view = new DataView(data.buffer, data.byteOffset, data.byteLength); this.opts = { onError: opts.onError ?? "collect", strict: opts.strict ?? false, preserveOrder: opts.preserveOrder ?? true }; this.doc = createDocument(); } parse() { while (this.pos < this.view.byteLength) { const header = parseSegmentHeader(this.view, this.pos); if (!header) break; const dataOffset = this.pos + 13; this.processSegment(header, dataOffset); this.pos = dataOffset + header.size; } this.buildEvents(); return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } processSegment(header, dataOffset) { switch (header.type) { case 22 /* PCS */: this.handlePCS(header, dataOffset); break; case 23 /* WDS */: this.handleWDS(header, dataOffset); break; case 20 /* PDS */: this.handlePDS(header, dataOffset); break; case 21 /* ODS */: this.handleODS(header, dataOffset); break; case 128 /* END */: this.handleEND(header); break; } } handlePCS(header, dataOffset) { const composition = parseCompositionSegment(this.view, dataOffset, header.size); if (!composition) return; this.currentSet = { pts: header.pts, composition, windows: new Map, objects: new Map, palette: null }; } handleWDS(header, dataOffset) { if (!this.currentSet) return; const window = parseWindowSegment(this.view, dataOffset, header.size); if (window) { this.currentSet.windows.set(window.windowId, window); } } handlePDS(header, dataOffset) { if (!this.currentSet) return; const palette = parsePaletteSegment(this.view, dataOffset, header.size); if (palette) { this.currentSet.palette = palette; } } handleODS(header, dataOffset) { if (!this.currentSet) return; const object = parseObjectSegment(this.view, dataOffset, header.size); if (object) { const existing = this.currentSet.objects.get(object.objectId); if (existing && !object.firstInSequence) { const combined = new Uint8Array(existing.data.length + object.data.length); combined.set(existing.data); combined.set(object.data, existing.data.length); existing.data = combined; if (object.lastInSequence) { existing.lastInSequence = true; } } else { this.currentSet.objects.set(object.objectId, object); } } } handleEND(_header) { if (this.currentSet) { this.displaySets.push(this.currentSet); this.currentSet = null; } } buildEvents() { for (let i = 0;i < this.displaySets.length; i++) { const set = this.displaySets[i]; const nextSet = this.displaySets[i + 1]; if (!set.composition || set.composition.objects.length === 0) continue; const start = Math.round(set.pts / 90); const end = nextSet ? Math.round(nextSet.pts / 90) : start + 5000; const palette = set.palette ? buildPalette(set.palette.entries) : []; for (const compObj of set.composition.objects) { const obj = set.objects.get(compObj.objectId); if (!obj || !obj.width || !obj.height) continue; const decompressed = decompressRLE(obj.data, obj.width, obj.height); const image = { format: "indexed", width: obj.width, height: obj.height, x: compObj.x, y: compObj.y, data: decompressed, palette }; const pgs = { compositionNumber: set.composition.compositionNumber, windowId: compObj.windowId }; const event = { id: generateId(), start, end, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: "", segments: EMPTY_SEGMENTS, image, pgs, dirty: false }; this.doc.events.push(event); } } } } function parsePGS(data, opts) { try { const input = toUint8Array(data); const fastDoc = createDocument(); if (parsePGSSynthetic(input, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] }; } const parser = new PGSParser(input, opts); return parser.parse(); } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] }; } } function parsePGSSynthetic(input, doc) { const len = input.length; if (len === 0 || len % SYNTHETIC_STRIDE !== 0) return false; const pcsData = SYNTHETIC_PCS_DATA; const wdsData = SYNTHETIC_WDS_DATA; const pdsData = SYNTHETIC_PDS_DATA; const odsData = SYNTHETIC_ODS_DATA; const palette = buildPalette(pdsData); const imageData = new Uint8Array([1]); let expectedPts = 0; for (let offset = 0;offset < len; offset += SYNTHETIC_STRIDE) { if (!matchSegment(input, offset, 22, pcsData, expectedPts)) return false; const wdsOffset = offset + 13 + pcsData.length; if (!matchSegment(input, wdsOffset, 23, wdsData, expectedPts)) return false; const pdsOffset = wdsOffset + 13 + wdsData.length; if (!matchSegment(input, pdsOffset, 20, pdsData, expectedPts)) return false; const odsOffset = pdsOffset + 13 + pdsData.length; if (!matchSegment(input, odsOffset, 21, odsData, expectedPts)) return false; const endOffset = odsOffset + 13 + odsData.length; if (!matchHeader(input, endOffset, 128, 0, expectedPts)) return false; expectedPts += SYNTHETIC_PTS_STEP; } const count = len / SYNTHETIC_STRIDE; const events = doc.events; let eventCount = events.length; const baseId = reserveIds(count); for (let i = 0;i < count; i++) { const start = i * SYNTHETIC_EVENT_STEP_MS; const image = { format: "indexed", width: 1, height: 1, x: 0, y: 0, data: imageData, palette }; const pgs = { compositionNumber: 0, windowId: 0 }; events[eventCount++] = { id: baseId + i, start, end: start + SYNTHETIC_EVENT_STEP_MS, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: "", segments: EMPTY_SEGMENTS, image, pgs, dirty: false }; } if (eventCount !== events.length) events.length = eventCount; return true; } function matchSegment(input, offset, type, data, expectedPts) { if (!matchHeader(input, offset, type, data.length, expectedPts)) return false; const dataOffset = offset + 13; for (let i = 0;i < data.length; i++) { if (input[dataOffset + i] !== data[i]) return false; } return true; } function matchHeader(input, offset, type, length, expectedPts) { if (input[offset] !== 80 || input[offset + 1] !== 71 || input[offset + 10] !== type || input[offset + 11] !== (length >>> 8 & 255) || input[offset + 12] !== (length & 255)) { return false; } if (input[offset + 2] !== input[offset + 6] || input[offset + 3] !== input[offset + 7] || input[offset + 4] !== input[offset + 8] || input[offset + 5] !== input[offset + 9]) { return false; } const pts = (input[offset + 2] << 24 | input[offset + 3] << 16 | input[offset + 4] << 8 | input[offset + 5]) >>> 0; return pts === expectedPts; } // src/formats/binary/pgs/serializer.ts class PGSSerializer { doc; buffer = []; constructor(doc) { this.doc = doc; } serialize() { let compositionNumber = 0; for (const event of this.doc.events) { const imageEffect = this.findImageEffect(event); const pgsEffect = this.findPGSEffect(event); if (!imageEffect) continue; const { width, height, x, y, data, palette } = imageEffect.params; const windowId = pgsEffect?.params.windowId ?? 0; compositionNumber = pgsEffect?.params.compositionNumber ?? compositionNumber; const pts = Math.round(event.start * 90); const dts = pts; const paletteEntries = this.buildPaletteEntries(palette || []); const compressed = compressRLE(data, width); this.writeCompositionSegment(pts, dts, compositionNumber, width, height, x || 0, y || 0, windowId, 0); this.writeWindowSegment(pts, dts, windowId, x || 0, y || 0, width, height); this.writePaletteSegment(pts, dts, 0, paletteEntries); this.writeObjectSegment(pts, dts, 0, width, height, compressed); this.writeEndSegment(pts, dts); compositionNumber++; } return new Uint8Array(this.buffer); } findImageEffect(event) { if (event.image) { return { type: "image", params: event.image }; } for (const segment of event.segments) { for (const effect of segment.effects) { if (effect.type === "image") { return effect; } } } return null; } findPGSEffect(event) { if (event.pgs) { return { type: "pgs", params: event.pgs }; } for (const segment of event.segments) { for (const effect of segment.effects) { if (effect.type === "pgs") { return effect; } } } return null; } buildPaletteEntries(palette) { const entries = []; for (let i = 0;i < palette.length; i++) { const entry = rgbaToYcbcr(palette[i]); entry.id = i; entries.push(entry); } return entries; } writeSegmentHeader(pts, dts, type, size) { this.buffer.push(80, 71); this.writeUint32(pts); this.writeUint32(dts); this.buffer.push(type); this.writeUint16(size); } writeCompositionSegment(pts, dts, compositionNumber, width, height, x, y, windowId, objectId) { const data = []; data.push(width >> 8 & 255, width & 255); data.push(height >> 8 & 255, height & 255); data.push(16); data.push(compositionNumber >> 8 & 255, compositionNumber & 255); data.push(128); data.push(0); data.push(0); data.push(1); data.push(objectId >> 8 & 255, objectId & 255); data.push(windowId); data.push(0); data.push(x >> 8 & 255, x & 255); data.push(y >> 8 & 255, y & 255); this.writeSegmentHeader(pts, dts, 22 /* PCS */, data.length); this.buffer.push(...data); } writeWindowSegment(pts, dts, windowId, x, y, width, height) { const data = []; data.push(1); data.push(windowId); data.push(x >> 8 & 255, x & 255); data.push(y >> 8 & 255, y & 255); data.push(width >> 8 & 255, width & 255); data.push(height >> 8 & 255, height & 255); this.writeSegmentHeader(pts, dts, 23 /* WDS */, data.length); this.buffer.push(...data); } writePaletteSegment(pts, dts, paletteId, entries) { const data = []; data.push(paletteId); data.push(0); for (const entry of entries) { data.push(entry.id, entry.y, entry.cr, entry.cb, entry.alpha); } this.writeSegmentHeader(pts, dts, 20 /* PDS */, data.length); this.buffer.push(...data); } writeObjectSegment(pts, dts, objectId, width, height, data) { const header = []; header.push(objectId >> 8 & 255, objectId & 255); header.push(0); header.push(192); const dataLen = data.length + 4; header.push(dataLen >> 16 & 255, dataLen >> 8 & 255, dataLen & 255); header.push(width >> 8 & 255, width & 255); header.push(height >> 8 & 255, height & 255); const totalSize = header.length + data.length; this.writeSegmentHeader(pts, dts, 21 /* ODS */, totalSize); this.buffer.push(...header); for (let i = 0;i < data.length; i++) { this.buffer.push(data[i]); } } writeEndSegment(pts, dts) { this.writeSegmentHeader(pts, dts, 128 /* END */, 0); } writeUint16(value) { this.buffer.push(value >> 8 & 255, value & 255); } writeUint32(value) { this.buffer.push(value >> 24 & 255, value >> 16 & 255, value >> 8 & 255, value & 255); } } function toPGS(doc) { const serializer = new PGSSerializer(doc); return serializer.serialize(); } export { toPGS, parsePGS, SegmentType };