subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
666 lines (660 loc) • 19.5 kB
JavaScript
// 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
};