subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
590 lines (526 loc) • 18 kB
text/typescript
// 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')
}