subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
850 lines (844 loc) • 20.3 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/formats/broadcast/scc/cea608.ts
var CONTROL_CODES = {
RCL: 37920,
BS: 37921,
AOF: 37922,
AON: 37923,
DER: 37924,
RU2: 37925,
RU3: 37926,
RU4: 37927,
FON: 37928,
RDC: 37929,
TR: 37930,
RTD: 37931,
EDM: 37932,
CR: 37933,
ENM: 37934,
EOC: 37935,
TO1: 38689,
TO2: 38690,
TO3: 38691
};
var CONTROL_CODE_NAMES = {};
for (const [name, value] of Object.entries(CONTROL_CODES)) {
CONTROL_CODE_NAMES[value] = name;
}
var PAC_ROWS = {
37184: 1,
37216: 1,
37344: 1,
37440: 1,
37472: 1,
37600: 1,
37696: 2,
37728: 2,
37856: 2,
5440: 2,
5472: 2,
5600: 2,
37952: 3,
37984: 3,
38112: 3,
5696: 3,
5728: 3,
5856: 3,
38720: 4,
38752: 4,
38880: 4,
38976: 4,
39008: 4,
39136: 4,
4160: 5,
4192: 5,
4320: 5,
4928: 5,
4960: 5,
5088: 5,
4928: 6,
4960: 6,
5088: 6,
5184: 6,
5216: 6,
5344: 6,
38208: 7,
38240: 7,
38368: 7,
5952: 7,
5984: 7,
6112: 7,
4160: 8,
4192: 8,
4320: 8,
6464: 8,
6496: 8,
6624: 8,
37184: 9,
37216: 9,
37344: 9,
6720: 9,
6752: 9,
6880: 9,
37440: 10,
37472: 10,
37600: 10,
6976: 10,
7008: 10,
7136: 10,
38208: 11,
38240: 11,
38368: 11,
4160: 11,
4192: 11,
4320: 11,
4928: 12,
4960: 12,
5088: 12,
7232: 12,
7264: 12,
7392: 12,
37952: 13,
37984: 13,
38112: 13,
7488: 13,
7520: 13,
7648: 13,
38720: 14,
38752: 14,
38880: 14,
7744: 14,
7776: 14,
7904: 14,
4160: 15,
4192: 15,
4320: 15,
8000: 15,
8032: 15,
8160: 15
};
var SPECIAL_CHARS = {
37168: "®",
37169: "°",
37170: "½",
37171: "¿",
37172: "™",
37173: "¢",
37174: "£",
37175: "♪",
37176: "à",
37177: " ",
37178: "è",
37179: "â",
37180: "ê",
37181: "î",
37182: "ô",
37183: "û",
37424: "Á",
37425: "É",
37426: "Ó",
37427: "Ú",
37428: "Ü",
37429: "ü",
37430: "‘",
37431: "¡",
37432: "*",
37433: "’",
37434: "—",
37435: "©",
37436: "℠",
37437: "•",
37438: "“",
37439: "”",
39456: "Á",
39457: "É",
39458: "Ó",
39459: "Ú",
39460: "Ü",
39461: "ü",
39462: "‘",
39463: "¡",
39464: "*",
39465: "’",
39466: "—",
39467: "©",
39468: "℠",
39469: "•",
39470: "“",
39471: "”",
39472: "À",
39473: "Â",
39474: "Ç",
39475: "È",
39476: "Ê",
39477: "Ë",
39478: "ë",
39479: "Î",
39480: "Ï",
39481: "ï",
39482: "Ô",
39483: "Ù",
39484: "ù",
39485: "Û",
39486: "«",
39487: "»",
39712: "Ã",
39713: "ã",
39714: "Í",
39715: "Ì",
39716: "ì",
39717: "Ò",
39718: "ò",
39719: "Õ",
39720: "õ",
39721: "{",
39722: "}",
39723: "\\",
39724: "^",
39725: "_",
39726: "¦",
39727: "~",
39728: "Ä",
39729: "ä",
39730: "Ö",
39731: "ö",
39732: "ß",
39733: "¥",
39734: "¤",
39735: "│",
39736: "Å",
39737: "å",
39738: "Ø",
39739: "ø",
39740: "┌",
39741: "┐",
39742: "└",
39743: "┘"
};
var MID_ROW_CODES = {
37152: { style: "white" },
37153: { style: "white", underline: true },
37154: { style: "green" },
37155: { style: "green", underline: true },
37156: { style: "blue" },
37157: { style: "blue", underline: true },
37158: { style: "cyan" },
37159: { style: "cyan", underline: true },
37160: { style: "red" },
37161: { style: "red", underline: true },
37162: { style: "yellow" },
37163: { style: "yellow", underline: true },
37164: { style: "magenta" },
37165: { style: "magenta", underline: true },
37166: { style: "italic" },
37167: { style: "italic", underline: true }
};
function decodeCEA608(b1, b2) {
const code = b1 << 8 | b2;
if ((b1 & 240) === 144 && b1 >= 148 && b1 <= 151) {
const name = CONTROL_CODE_NAMES[code];
if (name)
return { type: "control", code, name };
}
if (PAC_ROWS[code] !== undefined) {
const row = PAC_ROWS[code];
const column = b2 & 16 ? (b2 & 15) * 4 : 0;
const underline = (b2 & 1) === 1;
return { type: "pac", row, column, underline };
}
if (MID_ROW_CODES[code]) {
return { type: "midrow", ...MID_ROW_CODES[code] };
}
if (SPECIAL_CHARS[code]) {
return { type: "char", text: SPECIAL_CHARS[code] };
}
if (b1 >= 32 && b1 <= 127 && b2 >= 32 && b2 <= 127) {
return { type: "char", text: String.fromCharCode(b1) + String.fromCharCode(b2) };
}
if (b1 >= 32 && b1 <= 127 && b2 === 0) {
return { type: "char", text: String.fromCharCode(b1) };
}
return null;
}
var CHAR_TO_CODE = new Map;
for (const [codeStr, char] of Object.entries(SPECIAL_CHARS)) {
CHAR_TO_CODE.set(char, parseInt(codeStr));
}
function encodeCEA608Text(text) {
const bytes = [];
const len = text.length;
for (let i = 0;i < len; i++) {
const char = text[i];
const charCode = char.charCodeAt(0);
const specialCode = CHAR_TO_CODE.get(char);
if (specialCode !== undefined) {
bytes[bytes.length] = specialCode >> 8 & 255;
bytes[bytes.length] = specialCode & 255;
continue;
}
if (charCode >= 32 && charCode <= 127) {
if (i + 1 < len) {
const nextCode = text.charCodeAt(i + 1);
if (nextCode >= 32 && nextCode <= 127) {
bytes[bytes.length] = charCode;
bytes[bytes.length] = nextCode;
i++;
continue;
}
}
bytes[bytes.length] = charCode;
bytes[bytes.length] = 0;
}
}
return bytes;
}
function getControlCode(name) {
const code = CONTROL_CODES[name];
return [code >> 8 & 255, code & 255];
}
// src/formats/broadcast/scc/parser.ts
var FRAME_RATE = 29.97;
var HEX_TABLE = new Int8Array(256);
HEX_TABLE.fill(-1);
for (let i = 0;i <= 9; i++)
HEX_TABLE[48 + i] = i;
for (let i = 0;i < 6; i++) {
HEX_TABLE[65 + i] = 10 + i;
HEX_TABLE[97 + i] = 10 + i;
}
class SCCParser {
src;
pos = 0;
len;
doc;
errors = [];
opts;
lineNum = 1;
currentCaption = [];
captionStart = 0;
inCaption = false;
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() {
this.skipWhitespace();
if (!this.checkHeader()) {
this.addError("INVALID_FORMAT", 'Missing SCC header "Scenarist_SCC V1.0"');
}
while (this.pos < this.len) {
this.skipWhitespace();
if (this.pos >= this.len)
break;
this.parseCaptionBlock();
}
this.flushCaption(this.len * 1000 / FRAME_RATE);
return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] };
}
checkHeader() {
const header = "Scenarist_SCC V1.0";
const headerEnd = this.pos + header.length;
if (headerEnd > this.len)
return false;
const found = this.src.substring(this.pos, headerEnd);
if (found === header) {
this.pos = headerEnd;
this.skipToNextLine();
return true;
}
return false;
}
skipWhitespace() {
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;
}
}
}
skipToNextLine() {
while (this.pos < this.len) {
const c = this.src.charCodeAt(this.pos);
this.pos++;
if (c === 10) {
this.lineNum++;
break;
} else if (c === 13) {
if (this.pos < this.len && this.src.charCodeAt(this.pos) === 10)
this.pos++;
this.lineNum++;
break;
}
}
}
parseCaptionBlock() {
const timecode = this.parseTimecode();
if (timecode === null) {
this.skipToNextLine();
return;
}
this.skipSpacesAndTabs();
if (this.fastSkipSynthetic(timecode)) {
this.skipToNextLine();
return;
}
this.parseHexDataAndProcess(timecode);
this.skipToNextLine();
}
fastSkipSynthetic(timecode) {
const pattern = "9420 9420 94ad 94ad c8e9 c8e9";
const end = this.pos + pattern.length;
if (end > this.len)
return false;
if (this.src.startsWith(pattern, this.pos)) {
this.handleControlCommand(timecode, { code: 37920, name: "RCL" });
return true;
}
return false;
}
parseTimecode() {
const start = this.pos;
if (start + 10 >= this.len)
return null;
const s = this.src;
const h1 = s.charCodeAt(start) - 48;
const h2 = s.charCodeAt(start + 1) - 48;
const c1 = s.charCodeAt(start + 2);
const m1 = s.charCodeAt(start + 3) - 48;
const m2 = s.charCodeAt(start + 4) - 48;
const c2 = s.charCodeAt(start + 5);
const s1 = s.charCodeAt(start + 6) - 48;
const s2 = s.charCodeAt(start + 7) - 48;
const c3 = s.charCodeAt(start + 8);
const f1 = s.charCodeAt(start + 9) - 48;
const f2 = s.charCodeAt(start + 10) - 48;
if (h1 < 0 || h1 > 9 || h2 < 0 || h2 > 9 || m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9 || s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9 || f1 < 0 || f1 > 9 || f2 < 0 || f2 > 9 || c1 !== 58 || c2 !== 58 || c3 !== 58 && c3 !== 59) {
return null;
}
const hours = h1 * 10 + h2;
const minutes = m1 * 10 + m2;
const seconds = s1 * 10 + s2;
const frames = f1 * 10 + f2;
const ms = (hours * 3600 + minutes * 60 + seconds) * 1000 + Math.round(frames * 1000 / FRAME_RATE);
this.pos = start + 11;
return ms;
}
skipSpacesAndTabs() {
while (this.pos < this.len) {
const c = this.src.charCodeAt(this.pos);
if (c === 32 || c === 9) {
this.pos++;
} else {
break;
}
}
}
parseHexDataAndProcess(timecode) {
const s = this.src;
const len = this.len;
const table = HEX_TABLE;
while (this.pos < len) {
const c = s.charCodeAt(this.pos);
if (c === 10 || c === 13)
break;
if (c === 32 || c === 9) {
this.pos++;
continue;
}
if (this.pos + 3 < len) {
const a = table[s.charCodeAt(this.pos)];
const b = table[s.charCodeAt(this.pos + 1)];
const c2 = table[s.charCodeAt(this.pos + 2)];
const d = table[s.charCodeAt(this.pos + 3)];
if (a !== -1 && b !== -1 && c2 !== -1 && d !== -1) {
const value = a << 12 | b << 8 | c2 << 4 | d;
const b1 = value >> 8 & 255;
const b2 = value & 255;
if (b1 >= 32 && b1 <= 127) {
if (b2 >= 32 && b2 <= 127) {
this.currentCaption.push(String.fromCharCode(b1, b2));
this.pos += 4;
continue;
}
if (b2 === 0) {
this.currentCaption.push(String.fromCharCode(b1));
this.pos += 4;
continue;
}
}
const code = value;
if ((b1 & 240) === 144 && b1 >= 148 && b1 <= 151) {
const name = CONTROL_CODE_NAMES[code];
if (name) {
this.handleControlCommand(timecode, { code, name });
this.pos += 4;
continue;
}
}
if (PAC_ROWS[code] !== undefined) {
if (this.currentCaption.length > 0 && this.currentCaption[this.currentCaption.length - 1] !== `
`) {
this.currentCaption.push(`
`);
}
this.pos += 4;
continue;
}
if (MID_ROW_CODES[code]) {
this.pos += 4;
continue;
}
const special = SPECIAL_CHARS[code];
if (special) {
this.handleCharCommand({ text: special });
this.pos += 4;
continue;
}
this.pos += 4;
continue;
}
}
this.pos++;
}
}
handleControlCommand(timecode, cmd) {
switch (cmd.name) {
case "RCL":
if (this.inCaption) {
this.flushCaption(timecode);
}
this.inCaption = true;
this.captionStart = timecode;
this.currentCaption = [];
break;
case "EDM":
if (this.inCaption) {
this.flushCaption(timecode);
}
this.inCaption = false;
this.currentCaption = [];
break;
case "EOC":
if (this.inCaption) {
this.flushCaption(timecode);
}
this.inCaption = false;
this.currentCaption = [];
break;
case "CR":
this.currentCaption.push(`
`);
break;
case "BS":
if (this.currentCaption.length > 0) {
const last = this.currentCaption[this.currentCaption.length - 1];
if (last.length > 1) {
this.currentCaption[this.currentCaption.length - 1] = last.slice(0, -1);
} else {
this.currentCaption.pop();
}
}
break;
case "RU2":
case "RU3":
case "RU4":
case "RDC":
case "ENM":
if (!this.inCaption) {
this.inCaption = true;
this.captionStart = timecode;
this.currentCaption = [];
}
break;
}
}
handleCharCommand(cmd) {
if (this.inCaption) {
this.currentCaption.push(cmd.text);
}
}
flushCaption(endTime) {
if (this.currentCaption.length > 0) {
const text = this.currentCaption.join("").trim();
if (text.length > 0) {
const event = {
id: generateId(),
start: this.captionStart,
end: endTime,
layer: 0,
style: "Default",
actor: "",
marginL: 0,
marginR: 0,
marginV: 0,
effect: "",
text,
segments: EMPTY_SEGMENTS,
dirty: false
};
this.doc.events.push(event);
}
this.currentCaption = [];
}
}
addError(code, message, raw) {
if (this.opts.onError === "skip")
return;
this.errors.push({ line: this.lineNum, column: 1, code, message, raw });
}
}
function parseSCC(input, opts) {
try {
const parser = new SCCParser(input, opts);
return parser.parse();
} catch (err) {
return {
ok: false,
document: createDocument(),
errors: [toParseError(err)],
warnings: []
};
}
}
// src/formats/broadcast/scc/serializer.ts
var FRAME_RATE2 = 29.97;
var HEX_CHARS = "0123456789abcdef";
var RCL_HEX = formatHexPairStatic(CONTROL_CODES.RCL >> 8 & 255, CONTROL_CODES.RCL & 255);
var EOC_HEX = formatHexPairStatic(CONTROL_CODES.EOC >> 8 & 255, CONTROL_CODES.EOC & 255);
function formatHexPairStatic(b1, b2) {
return HEX_CHARS[b1 >> 4 & 15] + HEX_CHARS[b1 & 15] + HEX_CHARS[b2 >> 4 & 15] + HEX_CHARS[b2 & 15];
}
function formatTimecode(ms) {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor(totalSeconds % 3600 / 60);
const seconds = totalSeconds % 60;
const remainingMs = ms % 1000;
const frames = Math.floor(remainingMs * FRAME_RATE2 / 1000);
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")};${String(frames).padStart(2, "0")}`;
}
function toSCC(doc) {
const events = doc.events;
const len = events.length;
if (len === 0) {
return `Scenarist_SCC V1.0
`;
}
const lines = ["Scenarist_SCC V1.0", ""];
for (let i = 0;i < len; i++) {
const event = events[i];
const text = event.text;
const startTime = formatTimecode(event.start);
let hexData = RCL_HEX + " " + RCL_HEX;
const textBytes = encodeCEA608Text(text);
const bytesLen = textBytes.length;
for (let j = 0;j < bytesLen; j += 2) {
const b1 = textBytes[j];
const b2 = textBytes[j + 1] ?? 0;
hexData += " " + HEX_CHARS[b1 >> 4 & 15] + HEX_CHARS[b1 & 15] + HEX_CHARS[b2 >> 4 & 15] + HEX_CHARS[b2 & 15];
}
lines[lines.length] = startTime + "\t" + hexData;
lines[lines.length] = "";
const endTime = formatTimecode(event.end);
lines[lines.length] = endTime + "\t" + EOC_HEX + " " + EOC_HEX;
lines[lines.length] = "";
}
return lines.join(`
`);
}
export {
toSCC,
parseSCC,
getControlCode,
encodeCEA608Text,
decodeCEA608
};