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