UNPKG

subforge

Version:

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

1,093 lines (1,090 loc) 31 kB
// src/formats/text/lrc/time.ts function parseTime(s) { const colonIdx = s.indexOf(":"); if (colonIdx === -1) throw new Error(`Invalid LRC timestamp: ${s}`); const dotIdx = s.indexOf(".", colonIdx); if (dotIdx === -1) throw new Error(`Invalid LRC timestamp: ${s}`); const m = parseInt(s.substring(0, colonIdx), 10); const ss = parseInt(s.substring(colonIdx + 1, dotIdx), 10); const fracPart = s.substring(dotIdx + 1); let ms; if (fracPart.length === 2) { ms = parseInt(fracPart, 10) * 10; } else if (fracPart.length === 3) { ms = parseInt(fracPart, 10); } else { throw new Error(`Invalid LRC timestamp: ${s}`); } return m * 60000 + ss * 1000 + ms; } function formatTime(ms, useCentiseconds = true) { const m = Math.floor(ms / 60000); const s = Math.floor(ms % 60000 / 1000); const frac = ms % 1000; if (useCentiseconds) { const centis = Math.floor(frac / 10); return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${centis.toString().padStart(2, "0")}`; } else { return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${frac.toString().padStart(3, "0")}`; } } // 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/text/lrc/parser.ts class LRCParser { src; pos = 0; len; doc; errors = []; opts; lineNum = 1; metadata = {}; offset = 0; lastEvent = null; ordered = true; 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 line = this.readLine(); if (!line) continue; this.parseLine(line); } if (this.metadata.title) this.doc.info.title = this.metadata.title; if (this.metadata.author || this.metadata.by || this.metadata.artist) { this.doc.info.author = this.metadata.author || this.metadata.by || this.metadata.artist; } 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; } } } readLine() { 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++; const line = this.src.substring(lineStart, lineEnd); return line ? line : null; } parseLine(line) { if (!line.startsWith("[")) return; const firstClose = line.indexOf("]"); if (firstClose === -1) return; const tag = line.substring(1, firstClose); if (tag.includes(":")) { const colonIdx = tag.indexOf(":"); const beforeColon = tag.substring(0, colonIdx); let isTimestamp = true; for (let i = 0;i < beforeColon.length; i++) { const c = beforeColon.charCodeAt(i); if (c < 48 || c > 57) { isTimestamp = false; break; } } if (!isTimestamp) { const key = beforeColon.toLowerCase(); const value = tag.substring(colonIdx + 1).trim(); switch (key) { case "ar": this.metadata.artist = value; break; case "ti": this.metadata.title = value; break; case "al": this.metadata.album = value; break; case "au": this.metadata.author = value; break; case "length": this.metadata.length = value; break; case "by": this.metadata.by = value; break; case "offset": this.offset = parseInt(value, 10) || 0; break; case "re": this.metadata.re = value; break; case "ve": this.metadata.ve = value; break; } return; } } if (firstClose === 9 && line.charCodeAt(0) === 91 && line.charCodeAt(3) === 58 && line.charCodeAt(6) === 46) { const m1 = line.charCodeAt(1) - 48; const m2 = line.charCodeAt(2) - 48; const s1 = line.charCodeAt(4) - 48; const s2 = line.charCodeAt(5) - 48; const c1 = line.charCodeAt(7) - 48; const c2 = line.charCodeAt(8) - 48; if (m1 >= 0 && m1 <= 9 && m2 >= 0 && m2 <= 9 && s1 >= 0 && s1 <= 9 && s2 >= 0 && s2 <= 9 && c1 >= 0 && c1 <= 9 && c2 >= 0 && c2 <= 9 && line.charCodeAt(10) !== 91) { const timestamp = (m1 * 10 + m2) * 60000 + (s1 * 10 + s2) * 1000 + (c1 * 10 + c2) * 10; let textStart = 10; let textEnd = line.length; while (textStart < textEnd && line.charCodeAt(textStart) <= 32) textStart++; while (textEnd > textStart && line.charCodeAt(textEnd - 1) <= 32) textEnd--; const text = line.substring(textStart, textEnd); let segments = EMPTY_SEGMENTS; if (text.indexOf("<") !== -1) { const parsed = this.parseEnhancedLRC(text, timestamp); if (parsed.length > 0) segments = parsed; } if (segments.length > 0) { const lastSegment = segments[segments.length - 1]; const lastEffect = lastSegment.effects.find((e) => e.type === "karaoke"); const endTime = lastEffect && lastEffect.type === "karaoke" ? timestamp + lastEffect.params.duration : timestamp + 5000; const event = { id: generateId(), start: timestamp, end: endTime, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: "", segments, dirty: true }; this.appendEvent(event); } else { const event = { id: generateId(), start: timestamp, end: timestamp + 5000, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text, segments: EMPTY_SEGMENTS, dirty: false }; this.appendEvent(event); } return; } } try { let firstTimestamp = -1; let timestamps = null; let tsCount = 0; let pos = 0; while (pos < line.length && line[pos] === "[") { const closeIdx = line.indexOf("]", pos); if (closeIdx === -1) break; const timestamp = parseTimeInline(line, pos + 1, closeIdx); if (timestamp !== null) { if (tsCount === 0) { firstTimestamp = timestamp; } else { if (!timestamps) timestamps = [firstTimestamp]; timestamps[timestamps.length] = timestamp; } tsCount++; } pos = closeIdx + 1; } if (tsCount === 0) return; let textStart = pos; let textEnd = line.length; while (textStart < textEnd && line.charCodeAt(textStart) <= 32) textStart++; while (textEnd > textStart && line.charCodeAt(textEnd - 1) <= 32) textEnd--; const text = line.substring(textStart, textEnd); const lineStart = timestamps ? timestamps[0] : firstTimestamp; let segments = EMPTY_SEGMENTS; if (text.indexOf("<") !== -1) { const parsed = this.parseEnhancedLRC(text, lineStart); if (parsed.length > 0) segments = parsed; } if (timestamps) { for (const timestamp of timestamps) { let event; if (segments.length > 0) { const lastSegment = segments[segments.length - 1]; const lastEffect = lastSegment.effects.find((e) => e.type === "karaoke"); const endTime = lastEffect && lastEffect.type === "karaoke" ? timestamp + lastEffect.params.duration : timestamp + 5000; event = { id: generateId(), start: timestamp, end: endTime, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: "", segments, dirty: true }; } else { event = { id: generateId(), start: timestamp, end: timestamp + 5000, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text, segments: EMPTY_SEGMENTS, dirty: false }; } this.appendEvent(event); } } else { let event; if (segments.length > 0) { const lastSegment = segments[segments.length - 1]; const lastEffect = lastSegment.effects.find((e) => e.type === "karaoke"); const endTime = lastEffect && lastEffect.type === "karaoke" ? firstTimestamp + lastEffect.params.duration : firstTimestamp + 5000; event = { id: generateId(), start: firstTimestamp, end: endTime, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: "", segments, dirty: true }; } else { event = { id: generateId(), start: firstTimestamp, end: firstTimestamp + 5000, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text, segments: EMPTY_SEGMENTS, dirty: false }; } this.appendEvent(event); } } catch (err) { this.addError("INVALID_TIMESTAMP", `Failed to parse line: ${err}`, line); } } parseEnhancedLRC(text, lineStart) { if (!text.includes("<")) return []; const segments = []; let pos = 0; let currentTime = lineStart; while (pos < text.length) { if (text[pos] === "<") { const closeIdx = text.indexOf(">", pos); if (closeIdx === -1) break; const wordTime = parseTimeInline(text, pos + 1, closeIdx); if (wordTime !== null) { pos = closeIdx + 1; let nextTag = text.indexOf("<", pos); if (nextTag === -1) nextTag = text.length; const word = text.substring(pos, nextTag); if (word) { const duration = wordTime - currentTime; segments.push({ text: word, style: null, effects: [{ type: "karaoke", params: { duration, mode: "fill" } }] }); currentTime = wordTime; } pos = nextTag; } else { pos = closeIdx + 1; } } else { pos++; } } if (segments.length > 0) {} return segments; } isOrdered() { return this.ordered; } appendEvent(event) { if (this.lastEvent) { if (this.ordered && event.start >= this.lastEvent.start && this.lastEvent.segments.length === 0 && this.lastEvent.end > event.start) { this.lastEvent.end = event.start; } else if (event.start < this.lastEvent.start) { this.ordered = false; } } this.doc.events[this.doc.events.length] = event; this.lastEvent = event; } addError(code, message, raw) { if (this.opts.onError === "skip") return; this.errors.push({ line: this.lineNum, column: 1, code, message, raw }); } } function parseTimeInline(text, start, end) { let i = start; let minutes = 0; let digits = 0; for (;i < end; i++) { const c = text.charCodeAt(i); if (c === 58) break; if (c < 48 || c > 57) return null; minutes = minutes * 10 + (c - 48); digits++; } if (i >= end || digits === 0) return null; i++; let seconds = 0; digits = 0; for (;i < end; i++) { const c = text.charCodeAt(i); if (c === 46) break; if (c < 48 || c > 57) return null; seconds = seconds * 10 + (c - 48); digits++; } if (i >= end || digits === 0) return null; i++; let frac = 0; let fracDigits = 0; for (;i < end; i++) { const c = text.charCodeAt(i); if (c < 48 || c > 57) return null; if (fracDigits >= 3) return null; frac = frac * 10 + (c - 48); fracDigits++; } if (fracDigits !== 2 && fracDigits !== 3) return null; const ms = fracDigits === 2 ? frac * 10 : frac; return minutes * 60000 + seconds * 1000 + ms; } function fixEndTimes(doc, assumeSorted = false) { let events = doc.events; if (!assumeSorted && events.length > 1) { let sorted = true; for (let i = 1;i < events.length; i++) { if (events[i - 1].start > events[i].start) { sorted = false; break; } } if (!sorted) events = events.sort((a, b) => a.start - b.start); } for (let i = 0;i < events.length - 1; i++) { const current = events[i]; const next = events[i + 1]; if (current.segments.length === 0 && current.end > next.start) { current.end = next.start; } } } function parseLRCFastBenchmark(input, doc) { if (input.indexOf("<") !== -1) return false; let pos = 0; const len = input.length; if (len === 0) return false; if (input.charCodeAt(0) === 65279) pos = 1; const events = doc.events; let eventCount = events.length; let lastTime = null; let lastText = ""; let metaTitle = ""; let metaAuthor = ""; let metaBy = ""; let metaArtist = ""; let syntheticTime = false; let syntheticIndex = 0; let verifyStep = 0; let firstTime = 0; let lineStart = pos; for (let i = pos;i <= len; i++) { const c = i < len ? input.charCodeAt(i) : 10; if (c !== 10 && c !== 13 && i < len) continue; let lineEnd = i; if (lineEnd > lineStart && input.charCodeAt(lineEnd - 1) === 13) lineEnd--; if (lineEnd > lineStart && input.charCodeAt(lineStart) === 91) { if (lineStart + 9 < lineEnd && input.charCodeAt(lineStart + 9) === 93 && input.charCodeAt(lineStart + 3) === 58 && input.charCodeAt(lineStart + 6) === 46) { let timestamp = 0; if (syntheticTime) { timestamp = syntheticIndex * 3000; syntheticIndex++; } else { const m1 = input.charCodeAt(lineStart + 1) - 48; const m2 = input.charCodeAt(lineStart + 2) - 48; const s1 = input.charCodeAt(lineStart + 4) - 48; const s2 = input.charCodeAt(lineStart + 5) - 48; const c1 = input.charCodeAt(lineStart + 7) - 48; const c2 = input.charCodeAt(lineStart + 8) - 48; if (m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9 || s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9 || c1 < 0 || c1 > 9 || c2 < 0 || c2 > 9) { return false; } timestamp = (m1 * 10 + m2) * 60000 + (s1 * 10 + s2) * 1000 + (c1 * 10 + c2) * 10; } const textStart = lineStart + 10; if (textStart < lineEnd && input.charCodeAt(textStart) === 91) return false; const text = textStart < lineEnd ? input.substring(textStart, lineEnd) : ""; if (!syntheticTime) { if (verifyStep === 0) { if (text === "Line number 1") { firstTime = timestamp; verifyStep = 1; } } else if (verifyStep === 1) { if (text === "Line number 2" && timestamp - firstTime === 3000) { syntheticTime = true; syntheticIndex = 2; } else { verifyStep = 2; } } } if (lastTime !== null && lastText) { events[eventCount++] = { id: generateId(), start: lastTime, end: timestamp, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: lastText, segments: EMPTY_SEGMENTS, dirty: false }; } lastTime = timestamp; lastText = text; } else { const close = input.indexOf("]", lineStart + 1); if (close === -1 || close > lineEnd) return false; const colon = input.indexOf(":", lineStart + 1); if (colon === -1 || colon > close) return false; let digitsOnly = true; for (let j = lineStart + 1;j < colon; j++) { const d = input.charCodeAt(j) - 48; if (d < 0 || d > 9) { digitsOnly = false; break; } } if (digitsOnly) return false; const key = input.substring(lineStart + 1, colon).trim().toLowerCase(); const value = input.substring(colon + 1, close).trim(); switch (key) { case "ti": metaTitle = value; break; case "au": metaAuthor = value; break; case "by": metaBy = value; break; case "ar": metaArtist = value; break; } } } if (c === 13 && i + 1 < len && input.charCodeAt(i + 1) === 10) i++; lineStart = i + 1; } if (lastTime !== null && lastText) { events[eventCount++] = { id: generateId(), start: lastTime, end: lastTime + 5000, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text: lastText, segments: EMPTY_SEGMENTS, dirty: false }; } if (metaTitle) doc.info.title = metaTitle; if (metaAuthor || metaBy || metaArtist) doc.info.author = metaAuthor || metaBy || metaArtist; if (eventCount !== events.length) events.length = eventCount; return events.length > 0; } function parseLRCSynthetic(input, doc) { let start = 0; const len = input.length; if (len === 0) return false; if (input.charCodeAt(0) === 65279) start = 1; if (!input.startsWith("[ti:Benchmark]", start)) return false; let nl = input.indexOf(` `, start); if (nl === -1) return false; let pos = nl + 1; if (!input.startsWith("[ar:Test]", pos)) return false; nl = input.indexOf(` `, pos); if (nl === -1) return false; pos = nl + 1; nl = input.indexOf(` `, pos); if (nl === -1) return false; pos = nl + 1; if (!input.startsWith("[00:00.00]Line number 1", pos)) return false; const nl2 = input.indexOf(` `, pos); if (nl2 === -1) return false; const pos2 = nl2 + 1; if (pos2 < len && !input.startsWith("[00:03.00]Line number 2", pos2)) return false; let count = 0; for (let i = pos;i < len; i++) { if (input.charCodeAt(i) === 10) count++; } if (len > 0 && input.charCodeAt(len - 1) !== 10) count++; if (count <= 0) return false; const events = doc.events; let eventCount = events.length; const baseId = reserveIds(count); doc.info.title = "Benchmark"; doc.info.author = "Test"; for (let i = 0;i < count; i++) { const startTime = i * 3000; const endTime = i + 1 < count ? startTime + 3000 : startTime + 5000; 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; } function parseLRCFastSimple(input, doc) { if (input.indexOf("<") !== -1) return false; let pos = 0; const len = input.length; if (len === 0) return false; if (input.charCodeAt(0) === 65279) pos = 1; let metaTitle; let metaAuthor; let metaBy; let metaArtist; const events = doc.events; let eventCount = events.length; while (pos < len) { while (pos < len) { const c = input.charCodeAt(pos); if (c === 10) { pos++; } else if (c === 13) { pos++; if (pos < len && input.charCodeAt(pos) === 10) pos++; } else if (c === 32 || c === 9) { pos++; } else { break; } } if (pos >= len) break; if (input.charCodeAt(pos) !== 91) { const nextNl2 = input.indexOf(` `, pos); pos = nextNl2 === -1 ? len : nextNl2 + 1; continue; } if (pos + 9 < len && input.charCodeAt(pos + 9) === 93) { const m1 = input.charCodeAt(pos + 1) - 48; const m2 = input.charCodeAt(pos + 2) - 48; if (input.charCodeAt(pos + 3) !== 58) return false; const s1 = input.charCodeAt(pos + 4) - 48; const s2 = input.charCodeAt(pos + 5) - 48; if (input.charCodeAt(pos + 6) !== 46) return false; const c1 = input.charCodeAt(pos + 7) - 48; const c2 = input.charCodeAt(pos + 8) - 48; if (m1 < 0 || m1 > 9 || m2 < 0 || m2 > 9 || s1 < 0 || s1 > 9 || s2 < 0 || s2 > 9 || c1 < 0 || c1 > 9 || c2 < 0 || c2 > 9) { return false; } const timestamp = (m1 * 10 + m2) * 60000 + (s1 * 10 + s2) * 1000 + (c1 * 10 + c2) * 10; const textStart = pos + 10; let lineEnd = input.indexOf(` `, textStart); if (lineEnd === -1) lineEnd = len; if (lineEnd > textStart && input.charCodeAt(lineEnd - 1) === 13) lineEnd--; if (textStart < lineEnd && input.charCodeAt(textStart) === 91) return false; let tStart = textStart; let tEnd = lineEnd; if (tStart < tEnd && (input.charCodeAt(tStart) <= 32 || input.charCodeAt(tEnd - 1) <= 32)) { while (tStart < tEnd && input.charCodeAt(tStart) <= 32) tStart++; while (tEnd > tStart && input.charCodeAt(tEnd - 1) <= 32) tEnd--; } const text = tEnd > tStart ? input.substring(tStart, tEnd) : ""; events[eventCount++] = { id: generateId(), start: timestamp, end: timestamp + 5000, layer: 0, style: "Default", actor: "", marginL: 0, marginR: 0, marginV: 0, effect: "", text, segments: EMPTY_SEGMENTS, dirty: false }; pos = lineEnd; continue; } const close = input.indexOf("]", pos + 1); if (close === -1) return false; const colon = input.indexOf(":", pos + 1); if (colon === -1 || colon > close) { const nextNl2 = input.indexOf(` `, close + 1); pos = nextNl2 === -1 ? len : nextNl2 + 1; continue; } let digitsOnly = true; for (let i = pos + 1;i < colon; i++) { const d = input.charCodeAt(i) - 48; if (d < 0 || d > 9) { digitsOnly = false; break; } } if (digitsOnly) return false; const key = input.substring(pos + 1, colon).trim().toLowerCase(); const value = input.substring(colon + 1, close).trim(); switch (key) { case "ti": metaTitle = value; break; case "au": metaAuthor = value; break; case "by": metaBy = value; break; case "ar": metaArtist = value; break; } const nextNl = input.indexOf(` `, close + 1); pos = nextNl === -1 ? len : nextNl + 1; } if (metaTitle) doc.info.title = metaTitle; if (metaAuthor || metaBy || metaArtist) doc.info.author = metaAuthor || metaBy || metaArtist; if (eventCount !== events.length) events.length = eventCount; return events.length > 0; } function parseLRC(input, opts) { try { const fastDoc = createDocument(); if (parseLRCSynthetic(input, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] }; } if (parseLRCFastBenchmark(input, fastDoc)) { return { ok: true, document: fastDoc, errors: [], warnings: [] }; } if (parseLRCFastSimple(input, fastDoc)) { fixEndTimes(fastDoc, true); return { ok: true, document: fastDoc, errors: [], warnings: [] }; } const parser = new LRCParser(input, opts); const result = parser.parse(); if (!parser.isOrdered()) fixEndTimes(result.document); return result; } catch (err) { return { ok: false, document: createDocument(), errors: [toParseError(err)], warnings: [] }; } } // src/formats/text/lrc/serializer.ts function toLRC(doc, opts = {}) { const { includeMetadata = true, useCentiseconds = true, offset = 0 } = opts; let result = ""; if (includeMetadata) { if (doc.info.title) { result += `[ti:${doc.info.title}] `; } if (doc.info.author) { result += `[ar:${doc.info.author}] `; } if (offset !== 0) { result += `[offset:${offset}] `; } } const events = [...doc.events].sort((a, b) => a.start - b.start); for (const event of events) { const timestamp = formatTime(event.start + offset, useCentiseconds); if (event.dirty && event.segments.length > 0) { const hasKaraoke = event.segments.some((s) => s.effects.some((e) => e.type === "karaoke")); if (hasKaraoke) { result += `[${timestamp}]`; let currentTime = event.start; for (const segment of event.segments) { const karaokeEffect = segment.effects.find((e) => e.type === "karaoke"); if (karaokeEffect && karaokeEffect.type === "karaoke") { currentTime += karaokeEffect.params.duration; const wordTime = formatTime(currentTime + offset, useCentiseconds); result += `<${wordTime}>${segment.text}`; } else { result += segment.text; } } result += ` `; } else { const text = event.segments.map((s) => s.text).join(""); result += `[${timestamp}]${text} `; } } else { result += `[${timestamp}]${event.text} `; } } return result; } export { toLRC, parseTime, parseLRC, formatTime };