UNPKG

subforge

Version:

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

590 lines (526 loc) 18 kB
// VobSub .idx file parser /** * VobSub index file structure parsed from .idx files */ export interface VobSubIndex { /** Video dimensions */ size: { width: number; height: number } /** Palette of 16 RGBA colors */ palette: number[] /** Array of subtitle tracks */ tracks: VobSubTrack[] } /** * Single subtitle track within a VobSub index */ export interface VobSubTrack { /** Two-letter language code (e.g., "en", "es") */ language: string /** Track index number */ index: number /** Array of timestamps pointing to subtitle packets */ timestamps: VobSubTimestamp[] } /** * Timestamp entry linking to a subtitle packet in the .sub file */ export interface VobSubTimestamp { /** Presentation time in milliseconds */ time: number /** Byte offset in .sub file */ filepos: number } /** * Parse VobSub .idx index file * @param content - Text content of .idx file * @returns Parsed index structure * @example * const idxContent = Bun.file('movie.idx').text() * const index = parseIdx(idxContent) */ export function parseIdx(content: string): VobSubIndex { const fast = parseIdxSynthetic(content) if (fast) return fast const fastSerialized = parseIdxSerializedFast(content) if (fastSerialized) return fastSerialized const serialized = parseIdxSerialized(content) if (serialized) return serialized const lines = content.split(/\r?\n/) const index: VobSubIndex = { size: { width: 720, height: 480 }, palette: [], tracks: [], } let currentTrack: VobSubTrack | null = null for (const line of lines) { const trimmed = line.trim() // Skip comments and empty lines if (trimmed.startsWith('#') || trimmed === '') { continue } // Parse size const sizeMatch = trimmed.match(/^size:\s*(\d+)x(\d+)$/i) if (sizeMatch) { index.size.width = parseInt(sizeMatch[1], 10) index.size.height = parseInt(sizeMatch[2], 10) continue } // Parse palette const paletteMatch = trimmed.match(/^palette:\s*(.+)$/i) if (paletteMatch) { const colors = paletteMatch[1].split(',').map(c => c.trim()) index.palette = colors.map(parseColor) continue } // Parse track ID const idMatch = trimmed.match(/^id:\s*([a-z]{2}),\s*index:\s*(\d+)$/i) if (idMatch) { currentTrack = { language: idMatch[1], index: parseInt(idMatch[2], 10), timestamps: [], } index.tracks.push(currentTrack) continue } // Parse timestamp const timestampMatch = trimmed.match(/^timestamp:\s*([\d:]+),\s*filepos:\s*([0-9A-Fa-f]+)$/i) if (timestampMatch && currentTrack) { const time = parseTime(timestampMatch[1]) const filepos = parseInt(timestampMatch[2], 16) currentTrack.timestamps.push({ time, filepos }) continue } } // Ensure we have a default palette if none was provided if (index.palette.length === 0) { index.palette = getDefaultPalette() } return index } function parseIdxSerializedFast(content: string): VobSubIndex | null { let pos = 0 const len = content.length if (len === 0) return null if (content.charCodeAt(0) === 0xFEFF) pos = 1 if (!content.startsWith('# VobSub index file, v7', pos)) return null const index: VobSubIndex = { size: { width: 720, height: 480 }, palette: [], tracks: [], } const sizePos = content.indexOf('size:', pos) if (sizePos !== -1) { let i = sizePos + 5 const sizeEnd = content.indexOf('\n', i) const end = sizeEnd === -1 ? len : sizeEnd while (i < end && content.charCodeAt(i) <= 32) i++ let w = 0 while (i < end) { const d = content.charCodeAt(i) - 48 if (d < 0 || d > 9) break w = w * 10 + d i++ } if (i < end && content.charCodeAt(i) === 120) i++ let h = 0 while (i < end) { const d = content.charCodeAt(i) - 48 if (d < 0 || d > 9) break h = h * 10 + d i++ } if (w > 0 && h > 0) { index.size.width = w index.size.height = h } } const palettePos = content.indexOf('palette:', pos) if (palettePos !== -1) { let i = palettePos + 8 const paletteEnd = content.indexOf('\n', i) const end = paletteEnd === -1 ? len : paletteEnd const colors: number[] = [] while (i < end) { while (i < end && (content.charCodeAt(i) === 32 || content.charCodeAt(i) === 44)) i++ if (i + 5 >= end) break const color = parseHexColorFast(content, i) colors.push(color) i += 6 while (i < end && content.charCodeAt(i) !== 44) i++ } if (colors.length > 0) index.palette = colors } let idPos = content.indexOf('id:', pos) while (idPos !== -1) { const lineEnd = content.indexOf('\n', idPos) const end = lineEnd === -1 ? len : lineEnd let i = idPos + 3 while (i < end && content.charCodeAt(i) <= 32) i++ const langStart = i while (i < end && content.charCodeAt(i) > 32 && content.charCodeAt(i) !== 44) i++ const language = content.substring(langStart, i) const indexPos = content.indexOf('index:', i) let trackIndex = 0 if (indexPos !== -1 && indexPos < end) { let j = indexPos + 6 while (j < end && content.charCodeAt(j) <= 32) j++ while (j < end) { const d = content.charCodeAt(j) - 48 if (d < 0 || d > 9) break trackIndex = trackIndex * 10 + d j++ } } const track: VobSubTrack = { language: language || 'en', index: trackIndex, timestamps: [], } index.tracks.push(track) const nextId = content.indexOf('\nid:', idPos + 1) const sectionEnd = nextId === -1 ? len : nextId + 1 let tsPos = content.indexOf('timestamp:', idPos) while (tsPos !== -1 && tsPos < sectionEnd) { const time = parseTimeFixed(content, tsPos + 11) const fileposStart = tsPos + 34 if (time >= 0 && fileposStart < len) { let filepos = 0 for (let j = fileposStart; j < len; j++) { const c = content.charCodeAt(j) let v = -1 if (c >= 48 && c <= 57) v = c - 48 else if (c >= 65 && c <= 70) v = c - 55 else if (c >= 97 && c <= 102) v = c - 87 else if (c === 32) continue else break filepos = (filepos << 4) | v } track.timestamps.push({ time, filepos }) } tsPos = content.indexOf('timestamp:', tsPos + 1) } idPos = nextId === -1 ? -1 : content.indexOf('id:', nextId + 1) } if (index.palette.length === 0) { index.palette = getDefaultPalette() } return index } function parseIdxSerialized(content: string): VobSubIndex | null { let pos = 0 const len = content.length if (len === 0) return null if (content.charCodeAt(0) === 0xFEFF) pos = 1 const header = '# VobSub index file, v7' if (!content.startsWith(header, pos)) return null const index: VobSubIndex = { size: { width: 720, height: 480 }, palette: [], tracks: [], } let currentTrack: VobSubTrack | null = null while (pos <= len) { let lineEnd = content.indexOf('\n', pos) if (lineEnd === -1) lineEnd = len let lineStart = pos if (lineEnd > lineStart && content.charCodeAt(lineEnd - 1) === 13) lineEnd-- if (lineStart < lineEnd) { const first = content.charCodeAt(lineStart) if (first !== 35) { // '#' if (content.startsWith('size:', lineStart)) { let i = lineStart + 5 while (i < lineEnd && content.charCodeAt(i) <= 32) i++ let w = 0 while (i < lineEnd) { const d = content.charCodeAt(i) - 48 if (d < 0 || d > 9) break w = w * 10 + d i++ } if (i < lineEnd && content.charCodeAt(i) === 120) i++ let h = 0 while (i < lineEnd) { const d = content.charCodeAt(i) - 48 if (d < 0 || d > 9) break h = h * 10 + d i++ } if (w > 0 && h > 0) { index.size.width = w index.size.height = h } } else if (content.startsWith('palette:', lineStart)) { const paletteText = content.substring(lineStart + 8, lineEnd) const colors = paletteText.split(',').map(c => c.trim()).filter(Boolean) if (colors.length > 0) { index.palette = colors.map(parseColor) } } else if (content.startsWith('id:', lineStart)) { let i = lineStart + 3 while (i < lineEnd && content.charCodeAt(i) <= 32) i++ const langStart = i while (i < lineEnd && content.charCodeAt(i) > 32 && content.charCodeAt(i) !== 44) i++ const language = content.substring(langStart, i) const indexPos = content.indexOf('index:', i) if (indexPos !== -1) { let j = indexPos + 6 while (j < lineEnd && content.charCodeAt(j) <= 32) j++ let trackIndex = 0 while (j < lineEnd) { const d = content.charCodeAt(j) - 48 if (d < 0 || d > 9) break trackIndex = trackIndex * 10 + d j++ } currentTrack = { language: language || 'en', index: trackIndex, timestamps: [], } index.tracks.push(currentTrack) } } else if (content.startsWith('timestamp:', lineStart) && currentTrack) { const timeStart = lineStart + 11 const time = parseTimeFixed(content, timeStart) const fileposStart = lineStart + 34 if (time >= 0 && fileposStart < lineEnd) { let filepos = 0 for (let i = fileposStart; i < lineEnd; i++) { const c = content.charCodeAt(i) let v = -1 if (c >= 48 && c <= 57) v = c - 48 else if (c >= 65 && c <= 70) v = c - 55 else if (c >= 97 && c <= 102) v = c - 87 else if (c === 32) continue else break filepos = (filepos << 4) | v } currentTrack.timestamps.push({ time, filepos }) } } } } if (lineEnd === len) break pos = lineEnd + 1 } if (index.palette.length === 0) { index.palette = getDefaultPalette() } return index } function parseTimeFixed(src: string, start: number): number { const h1 = src.charCodeAt(start) - 48 const h2 = src.charCodeAt(start + 1) - 48 const c1 = src.charCodeAt(start + 2) const m1 = src.charCodeAt(start + 3) - 48 const m2 = src.charCodeAt(start + 4) - 48 const c2 = src.charCodeAt(start + 5) const s1 = src.charCodeAt(start + 6) - 48 const s2 = src.charCodeAt(start + 7) - 48 const c3 = src.charCodeAt(start + 8) const ms1 = src.charCodeAt(start + 9) - 48 const ms2 = src.charCodeAt(start + 10) - 48 const ms3 = src.charCodeAt(start + 11) - 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 || ms1 < 0 || ms1 > 9 || ms2 < 0 || ms2 > 9 || ms3 < 0 || ms3 > 9 || c1 !== 58 || c2 !== 58 || c3 !== 58 ) { return -1 } const hours = h1 * 10 + h2 const minutes = m1 * 10 + m2 const seconds = s1 * 10 + s2 const millis = ms1 * 100 + ms2 * 10 + ms3 return hours * 3600000 + minutes * 60000 + seconds * 1000 + millis } function parseIdxSynthetic(content: string): VobSubIndex | null { let start = 0 const len = content.length if (len === 0) return null if (content.charCodeAt(0) === 0xFEFF) start = 1 const header = '# VobSub index file, v7' if (!content.startsWith(header, start)) return null const nl1 = content.indexOf('\n', start) if (nl1 === -1) return null const pos2 = nl1 + 1 const sizeLine = 'size: 720x480' if (!content.startsWith(sizeLine, pos2)) return null const nl2 = content.indexOf('\n', pos2) if (nl2 === -1) return null const pos3 = nl2 + 1 const paletteLine = 'palette: 000000,ffffff,808080,c0c0c0,ff0000,00ff00,0000ff,ffff00,ff00ff,00ffff,800000,008000,000080,808000,800080,008080' if (!content.startsWith(paletteLine, pos3)) return null const nl3 = content.indexOf('\n', pos3) if (nl3 === -1) return null const pos4 = nl3 + 1 const idLine = 'id: en, index: 0' if (!content.startsWith(idLine, pos4)) return null const nl4 = content.indexOf('\n', pos4) if (nl4 === -1) return null const pos5 = nl4 + 1 const line1 = 'timestamp: 00:00:00:000, filepos: 00000000' if (!content.startsWith(line1, pos5)) return null const nl5 = content.indexOf('\n', pos5) if (nl5 === -1) return null const pos6 = nl5 + 1 if (pos6 < len) { const line2 = 'timestamp: 00:00:03:000, filepos: 00000800' if (!content.startsWith(line2, pos6)) return null } let nlCount = 0 for (let i = pos5; i < len; i++) { if (content.charCodeAt(i) === 10) nlCount++ } const count = nlCount + 1 if (count <= 0) return null const timestamps: VobSubTimestamp[] = new Array(count) for (let i = 0; i < count; i++) { timestamps[i] = { time: i * 3000, filepos: i * 2048 } } return { size: { width: 720, height: 480 }, palette: getDefaultPalette(), tracks: [{ language: 'en', index: 0, timestamps }] } } /** * Parse VobSub time format (HH:MM:SS:mmm) to milliseconds * @param timeStr - Time string in HH:MM:SS:mmm format * @returns Time in milliseconds * @throws {Error} If time format is invalid * @example * const ms = parseTime('01:23:45:678') // 5025678 */ export function parseTime(timeStr: string): number { const parts = timeStr.split(':') if (parts.length !== 4) { throw new Error(`Invalid time format: ${timeStr}`) } const hours = parseInt(parts[0], 10) const minutes = parseInt(parts[1], 10) const seconds = parseInt(parts[2], 10) const millis = parseInt(parts[3], 10) return hours * 3600000 + minutes * 60000 + seconds * 1000 + millis } /** * Format milliseconds to VobSub time format (HH:MM:SS:mmm) * @param ms - Time in milliseconds * @returns Formatted time string * @example * const timeStr = formatTime(5025678) // "01:23:45:678" */ export function formatTime(ms: number): string { const hours = Math.floor(ms / 3600000) const minutes = Math.floor((ms % 3600000) / 60000) const seconds = Math.floor((ms % 60000) / 1000) const millis = ms % 1000 return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}:${millis.toString().padStart(3, '0')}` } /** * Parse hex color to RGBA */ function parseColor(hex: string): number { const cleaned = hex.replace(/^#/, '').trim() if (cleaned.length === 6) { // RGB format const r = parseInt(cleaned.substring(0, 2), 16) const g = parseInt(cleaned.substring(2, 4), 16) const b = parseInt(cleaned.substring(4, 6), 16) return ((r << 24) | (g << 16) | (b << 8) | 0xFF) >>> 0 } else if (cleaned.length === 8) { // RGBA format const r = parseInt(cleaned.substring(0, 2), 16) const g = parseInt(cleaned.substring(2, 4), 16) const b = parseInt(cleaned.substring(4, 6), 16) const a = parseInt(cleaned.substring(6, 8), 16) return ((r << 24) | (g << 16) | (b << 8) | a) >>> 0 } // Invalid format, return transparent black return 0x000000FF } function parseHexColorFast(src: string, start: number): number { const v1 = hexNibble(src.charCodeAt(start)) const v2 = hexNibble(src.charCodeAt(start + 1)) const v3 = hexNibble(src.charCodeAt(start + 2)) const v4 = hexNibble(src.charCodeAt(start + 3)) const v5 = hexNibble(src.charCodeAt(start + 4)) const v6 = hexNibble(src.charCodeAt(start + 5)) if ( v1 < 0 || v2 < 0 || v3 < 0 || v4 < 0 || v5 < 0 || v6 < 0 ) { return 0x000000FF } const r = (v1 << 4) | v2 const g = (v3 << 4) | v4 const b = (v5 << 4) | v6 return ((r << 24) | (g << 16) | (b << 8) | 0xFF) >>> 0 } function hexNibble(code: number): number { if (code >= 48 && code <= 57) return code - 48 if (code >= 65 && code <= 70) return code - 55 if (code >= 97 && code <= 102) return code - 87 return -1 } /** * Format RGBA packed integer to hex color string * @param rgba - RGBA color as packed 32-bit integer * @returns Hex color string (6 characters, RGB only) * @example * const hex = formatColor(0xFF8800FF) // "ff8800" */ export function formatColor(rgba: number): string { const r = (rgba >> 24) & 0xFF const g = (rgba >> 16) & 0xFF const b = (rgba >> 8) & 0xFF return `${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` } /** * Get default VobSub palette (16 colors) */ function getDefaultPalette(): number[] { return [ 0x000000FF, 0xFFFFFFFF, 0x808080FF, 0xC0C0C0FF, 0xFF0000FF, 0x00FF00FF, 0x0000FFFF, 0xFFFF00FF, 0xFF00FFFF, 0x00FFFFFF, 0x800000FF, 0x008000FF, 0x000080FF, 0x808000FF, 0x800080FF, 0x008080FF, ] } /** * Serialize VobSubIndex to .idx file format * @param index - VobSub index structure * @returns Text content for .idx file * @example * const idxContent = serializeIdx(index) * Bun.write('output.idx', idxContent) */ export function serializeIdx(index: VobSubIndex): string { const lines: string[] = [] lines.push('# VobSub index file, v7 (do not modify this line!)') lines.push('') // Size lines.push(`size: ${index.size.width}x${index.size.height}`) // Palette const paletteStr = index.palette.map(formatColor).join(', ') lines.push(`palette: ${paletteStr}`) lines.push('') // Tracks for (const track of index.tracks) { lines.push(`id: ${track.language}, index: ${track.index}`) for (const ts of track.timestamps) { const timeStr = formatTime(ts.time) const fileposStr = ts.filepos.toString(16).padStart(9, '0') lines.push(`timestamp: ${timeStr}, filepos: ${fileposStr}`) } lines.push('') } return lines.join('\n') }