subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
465 lines (420 loc) • 13.8 kB
text/typescript
// VobSub format support
import type { SubtitleDocument, SubtitleEvent, ImageEffect, VobSubEffect } from '../../../core/types.ts'
import type { ParseOptions, ParseResult } from '../../../core/errors.ts'
import { toParseError } from '../../../core/errors.ts'
import { createDocument, EMPTY_SEGMENTS } from '../../../core/document.ts'
import { toUint8Array } from '../../../core/binary.ts'
import { parseIdx, serializeIdx, type VobSubIndex } from './parser.ts'
import { parseSubPacket, createSubBinary, type SubtitlePacket } from './sub.ts'
import { decodeRLE, encodeRLE } from './rle.ts'
export { parseTime, formatTime } from './parser.ts'
export type { VobSubIndex, VobSubTrack, VobSubTimestamp } from './parser.ts'
export type { SubtitlePacket } from './sub.ts'
export type VobSubParseOptions = ParseOptions & {
/**
* Decode image data.
* - 'full': decode to indexed bitmap (default)
* - 'rle': keep raw RLE data in the image effect
* - 'none': skip sub packet parsing and image data (timing only)
*/
decode?: 'full' | 'rle' | 'none'
}
export { decodeRLE, encodeRLE } from './rle.ts'
type VobSubDecodeMode = NonNullable<VobSubParseOptions['decode']>
/**
* Parse VobSub subtitle files (.idx + .sub)
* @param idx - Text content of .idx file or a pre-parsed index
* @param sub - Binary content of .sub file
* @returns ParseResult containing the document and any errors/warnings
* @example
* const idxText = await Bun.file('movie.idx').text()
* const subData = await Bun.file('movie.sub').arrayBuffer()
* const result = parseVobSub(idxText, new Uint8Array(subData))
*/
export function parseVobSub(
idx: string | VobSubIndex,
sub: Uint8Array | ArrayBuffer,
opts: Partial<VobSubParseOptions> = {}
): ParseResult {
try {
const data = toUint8Array(sub)
const errors: string[] = []
const decodeMode: VobSubDecodeMode = opts.decode ?? 'full'
const index = typeof idx === 'string'
? (decodeMode === 'none' ? parseIdxTimings(idx) : parseIdx(idx))
: idx
let totalEvents = 0
for (const track of index.tracks) totalEvents += track.timestamps.length
const doc: SubtitleDocument = {
info: {
title: 'VobSub',
playResX: index.size.width,
playResY: index.size.height,
scaleBorderAndShadow: true,
wrapStyle: 0,
},
styles: new Map([
['Default', {
name: 'Default',
fontName: 'Arial',
fontSize: 20,
primaryColor: 0xFFFFFFFF,
secondaryColor: 0x00FF00FF,
outlineColor: 0x000000FF,
backColor: 0x000000FF,
bold: false,
italic: false,
underline: false,
strikeout: false,
scaleX: 100,
scaleY: 100,
spacing: 0,
angle: 0,
borderStyle: 1,
outline: 2,
shadow: 0,
alignment: 2,
marginL: 10,
marginR: 10,
marginV: 10,
encoding: 1,
}],
]),
events: new Array(totalEvents),
comments: [],
}
let eventId = 0
const palette = index.palette
const defaultStyle = 'Default'
// Parse all tracks
for (const track of index.tracks) {
const timestamps = track.timestamps
const tlen = timestamps.length
const trackIndex = track.index
for (let i = 0; i < tlen; i++) {
const ts = timestamps[i]!
if (decodeMode === 'none') {
const next = i + 1 < tlen ? timestamps[i + 1]!.time : ts.time + 2000
const endTime = next > ts.time ? next : ts.time + 2000
const id = eventId++
const event: SubtitleEvent = {
id,
start: ts.time,
end: endTime,
layer: 0,
style: defaultStyle,
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: '',
segments: EMPTY_SEGMENTS,
dirty: false,
}
doc.events[id] = event
continue
}
const packet = parseSubPacket(data, ts.filepos)
if (!packet) {
errors.push(`Failed to parse packet at filepos ${ts.filepos.toString(16)}`)
continue
}
// Determine end time (use next timestamp or add duration)
let endTime = ts.time + packet.duration
if (packet.duration === 0 && i + 1 < tlen) {
endTime = timestamps[i + 1]!.time
}
if (endTime <= ts.time) {
endTime = ts.time + 2000 // Default 2 second duration
}
const id = eventId++
const event: SubtitleEvent = {
id,
start: ts.time,
end: endTime,
layer: 0,
style: defaultStyle,
actor: '',
marginL: 0,
marginR: 0,
marginV: 0,
effect: '',
text: '',
segments: EMPTY_SEGMENTS,
dirty: false,
}
if (decodeMode !== 'none') {
event.image = decodeMode === 'rle'
? {
format: 'rle',
width: packet.width,
height: packet.height,
x: packet.x,
y: packet.y,
data: packet.rleData,
palette,
}
: {
format: 'indexed',
width: packet.width,
height: packet.height,
x: packet.x,
y: packet.y,
data: decodeRLE(packet.rleData, packet.width, packet.height).data,
palette,
}
event.vobsub = {
forced: packet.forced,
originalIndex: trackIndex,
}
}
doc.events[id] = event
}
}
if (eventId < doc.events.length) {
doc.events.length = eventId
}
const parseErrors = errors.map(message => ({
line: 0,
column: 0,
code: 'MALFORMED_EVENT' as const,
message,
}))
return {
ok: parseErrors.length === 0,
document: doc,
errors: parseErrors,
warnings: [],
}
} catch (err) {
return {
ok: false,
document: createDocument(),
errors: [toParseError(err)],
warnings: []
}
}
}
function parseIdxTimings(content: string): VobSubIndex {
let pos = 0
const len = content.length
if (len === 0) {
return {
size: { width: 720, height: 480 },
palette: [],
tracks: [],
}
}
if (content.charCodeAt(0) === 0xFEFF) pos = 1
const index: VobSubIndex = {
size: { width: 720, height: 480 },
palette: [],
tracks: [],
}
let currentTrack = null as VobSubIndex['tracks'][number] | 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('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)) {
if (!currentTrack) {
currentTrack = { language: 'en', index: 0, timestamps: [] }
index.tracks.push(currentTrack)
}
const time = parseTimeFixed(content, lineStart + 11)
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
}
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
}
/**
* Convert SubtitleDocument to VobSub format
* @param doc - Subtitle document to convert
* @returns Object containing .idx text and .sub binary data
* @example
* const { idx, sub } = toVobSub(document)
* await Bun.write('output.idx', idx)
* await Bun.write('output.sub', sub)
*/
export function toVobSub(doc: SubtitleDocument): { idx: string; sub: Uint8Array } {
const index: VobSubIndex = {
size: {
width: doc.info.playResX,
height: doc.info.playResY,
},
palette: [],
tracks: [{
language: 'en',
index: 0,
timestamps: [],
}],
}
const packets: SubtitlePacket[] = []
let filepos = 0
for (const event of doc.events) {
const imageParams = event.image
?? event.segments
.flatMap(seg => seg.effects)
.find(eff => eff.type === 'image') as ImageEffect | undefined
if (!imageParams) {
continue
}
const params = 'type' in imageParams ? imageParams.params : imageParams
// Extract palette (use first event's palette for the whole file)
if (index.palette.length === 0 && params.palette) {
index.palette = params.palette
}
const vobsubMeta = event.vobsub
?? (event.segments
.flatMap(seg => seg.effects)
.find(eff => eff.type === 'vobsub') as VobSubEffect | undefined)?.params
// Encode bitmap to RLE
const rleData = params.format === 'rle'
? params.data
: encodeRLE(params.data, params.width, params.height)
const packet: SubtitlePacket = {
pts: event.start,
duration: event.end - event.start,
x: params.x || 0,
y: params.y || 0,
width: params.width,
height: params.height,
rleData,
forced: vobsubMeta?.forced || false,
}
packets.push(packet)
index.tracks[0].timestamps.push({
time: event.start,
filepos,
})
// Estimate filepos (will be recalculated when creating binary)
filepos += estimatePacketSize(rleData.length, packet.forced)
}
// Create .sub binary
const subBinary = createSubBinary(packets)
// Update filepos values with actual offsets
let actualPos = 0
for (let i = 0; i < packets.length; i++) {
index.tracks[0].timestamps[i].filepos = actualPos
// Calculate actual packet size
actualPos += estimatePacketSize(packets[i].rleData.length, packets[i].forced)
}
// Ensure we have a palette
if (index.palette.length === 0) {
// Use default palette
index.palette = [
0x000000FF, 0xFFFFFFFF, 0x808080FF, 0xC0C0C0FF,
0xFF0000FF, 0x00FF00FF, 0x0000FFFF, 0xFFFF00FF,
0xFF00FFFF, 0x00FFFFFF, 0x800000FF, 0x008000FF,
0x000080FF, 0x808000FF, 0x800080FF, 0x008080FF,
]
}
const idxContent = serializeIdx(index)
return {
idx: idxContent,
sub: subBinary,
}
}
function estimatePacketSize(rleSize: number, forced: boolean): number {
const controlSeqSize = forced ? 12 : 11
const subPacketSize = 4 + rleSize + controlSeqSize
const pesLength = 11 + subPacketSize
return 20 + pesLength
}