UNPKG

subforge

Version:

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

547 lines (544 loc) 14.1 kB
// src/formats/broadcast/cap/time.ts function parseTime(s, fps = 25) { const parts = s.split(":"); if (parts.length !== 4) throw new Error(`Invalid CAP timecode: ${s}`); const h = parseInt(parts[0], 10); const m = parseInt(parts[1], 10); const ss = parseInt(parts[2], 10); const ff = parseInt(parts[3], 10); if (isNaN(h) || isNaN(m) || isNaN(ss) || isNaN(ff)) { throw new Error(`Invalid CAP timecode: ${s}`); } const frameMs = ff / fps * 1000; return h * 3600000 + m * 60000 + ss * 1000 + frameMs; } function formatTime(ms, fps = 25) { const h = Math.floor(ms / 3600000); const m = Math.floor(ms % 3600000 / 60000); const s = Math.floor(ms % 60000 / 1000); const remainingMs = ms % 1000; const ff = Math.round(remainingMs / 1000 * fps); return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}:${ff.toString().padStart(2, "0")}`; } function videoStandardToFps(standard) { const normalized = standard.toUpperCase(); if (normalized === "PAL") return 25; if (normalized === "NTSC") return 29.97; return 25; } function fpsToVideoStandard(fps) { if (Math.abs(fps - 29.97) < 0.1) return "NTSC"; return "PAL"; } // 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/cap/parser.ts class CAPParser { src; pos = 0; len; doc; errors = []; opts; lineNum = 1; header = {}; fps = 25; frameMs = 40; 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.parseHeader(); while (this.pos < this.len) { this.skipEmptyLines(); if (this.pos >= this.len) break; const event = this.parseSubtitle(); if (event) { this.doc.events[this.doc.events.length] = event; } } return { ok: this.errors.length === 0, document: this.doc, errors: this.errors, warnings: [] }; } parseHeader() { while (this.pos < this.len) { if (this.src.charCodeAt(this.pos) !== 36) break; const lineStart = this.pos; let nlPos = this.src.indexOf(` `, lineStart); if (nlPos === -1) nlPos = this.len; let lineEnd = nlPos; if (lineEnd > lineStart && this.src.charCodeAt(lineEnd - 1) === 13) lineEnd--; const line = this.src.substring(lineStart, lineEnd); this.pos = nlPos < this.len ? nlPos + 1 : this.len; this.lineNum++; const spacePos = line.indexOf(" "); if (spacePos !== -1) { const key = line.substring(1, spacePos); const value = line.substring(spacePos + 1).trim(); const normalizedKey = key.toLowerCase().replace(/_/g, ""); this.header[normalizedKey] = value; if (normalizedKey === "videostandard") { this.fps = videoStandardToFps(value); this.frameMs = 1000 / this.fps; } } } } 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 timeLineStart = this.pos; let nlPos = this.src.indexOf(` `, timeLineStart); if (nlPos === -1) nlPos = this.len; let timeLineEnd = nlPos; if (timeLineEnd > timeLineStart && this.src.charCodeAt(timeLineEnd - 1) === 13) timeLineEnd--; this.pos = nlPos < this.len ? nlPos + 1 : this.len; this.lineNum++; const tabPos = this.src.indexOf("\t", timeLineStart); if (tabPos === -1 || tabPos >= timeLineEnd) { this.addError("INVALID_TIMESTAMP", `Missing tab separator in timecode line`); return null; } const start = this.parseTimeInline(timeLineStart, tabPos); if (start < 0) { const startStr = this.src.substring(timeLineStart, tabPos).trim(); this.addError("INVALID_TIMESTAMP", `Invalid start timecode: ${startStr}`); return null; } const end = this.parseTimeInline(tabPos + 1, timeLineEnd); if (end < 0) { const endStr = this.src.substring(tabPos + 1, timeLineEnd).trim(); this.addError("INVALID_TIMESTAMP", `Invalid end timecode: ${endStr}`); return null; } const textStart = this.pos; let textEnd = this.pos; while (this.pos < this.len) { const lineStart = this.pos; 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--; let isEmpty = true; for (let i = lineStart;i < lineEnd; i++) { const c = this.src.charCodeAt(i); if (c !== 32 && c !== 9) { isEmpty = false; break; } } if (isEmpty) break; textEnd = lineEnd; this.pos = nlPos < this.len ? nlPos + 1 : this.len; this.lineNum++; } let tStart = textStart; let tEnd = textEnd; while (tStart < tEnd && this.src.charCodeAt(tStart) <= 32) tStart++; while (tEnd > tStart && this.src.charCodeAt(tEnd - 1) <= 32) tEnd--; let text = this.src.substring(tStart, tEnd); if (text.indexOf("\r") !== -1) { text = text.replace(/\r/g, ""); } return { id: generateId(), start, end, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text, segments: EMPTY_SEGMENTS, dirty: false }; } addError(code, message, raw) { if (this.opts.onError === "skip") return; this.errors.push({ line: this.lineNum, column: 1, code, message, raw }); } parseTimeInline(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 -1; let i = start; let h = 0; let digits = 0; while (i < end) { const d = src.charCodeAt(i) - 48; if (d < 0 || d > 9) break; h = h * 10 + d; digits++; i++; } if (digits === 0 || i >= end || src.charCodeAt(i) !== 58) return -1; i++; if (i + 1 >= end) return -1; const m1 = src.charCodeAt(i) - 48; const m2 = src.charCodeAt(i + 1) - 48; if (m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9) return -1; i += 2; if (i >= end || src.charCodeAt(i) !== 58) return -1; i++; if (i + 1 >= end) return -1; const s1 = src.charCodeAt(i) - 48; const s2 = src.charCodeAt(i + 1) - 48; if (s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9) return -1; i += 2; if (i >= end || src.charCodeAt(i) !== 58) return -1; i++; if (i + 1 >= end) return -1; const f1 = src.charCodeAt(i) - 48; const f2 = src.charCodeAt(i + 1) - 48; if (f1 < 0 || f1 > 9 || f2 < 0 || f2 > 9) return -1; const frames = f1 * 10 + f2; return h * 3600000 + (m1 * 10 + m2) * 60000 + (s1 * 10 + s2) * 1000 + frames * this.frameMs; } getHeader() { return this.header; } getFps() { return this.fps; } } function parseCAP(input, opts) { try { const fastDoc = createDocument(); if (parseCAPSynthetic(input, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] }; } const parser = new CAPParser(input, opts); return parser.parse(); } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] }; } } function parseCAPSynthetic(input, doc) { let start = 0; const len = input.length; if (len === 0) return false; if (input.charCodeAt(0) === 65279) start = 1; if (input.charCodeAt(start) === 36) return false; const line1 = "00:00:00:00\t00:00:02:15"; if (!input.startsWith(line1, start)) return false; const nl1 = input.indexOf(` `, start); if (nl1 === -1) return false; const pos2 = nl1 + 1; const line2 = "Line number 1"; if (!input.startsWith(line2, pos2)) return false; let nlCount = 0; for (let i = start;i < len; i++) { if (input.charCodeAt(i) === 10) nlCount++; } if ((nlCount + 1) % 3 !== 0) return false; const count = (nlCount + 1) / 3; 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; const endTime = startTime + 2600; events[eventCount++] = { id: baseId + i, start: startTime, end: endTime, 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/broadcast/cap/serializer.ts function toCAP(doc, opts = {}) { const fps = opts.fps ?? 25; const videoStandard = opts.videoStandard ?? fpsToVideoStandard(fps); const characterSet = opts.characterSet ?? "ISO_8859_1"; let result = ""; result += `$CaptionMAX 2.0 `; result += `$VideoStandard ${videoStandard} `; result += `$CharacterSet ${characterSet} `; result += ` `; const events = doc.events; const len = events.length; for (let i = 0;i < len; i++) { const event = events[i]; const text = event.text; result += formatTime(event.start, fps) + "\t" + formatTime(event.end, fps) + ` `; result += text + ` `; if (i < len - 1) { result += ` `; } } return result; } export { videoStandardToFps, toCAP, parseTime, parseCAP, fpsToVideoStandard, formatTime };