UNPKG

subforge

Version:

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

850 lines (844 loc) 20.3 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/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 };