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