subforge
Version:
High-performance subtitle toolkit for parsing, converting, and authoring across 20+ formats.
481 lines (409 loc) • 12.7 kB
text/typescript
// VobSub .sub binary file parser (MPEG-PS packets)
/**
* Parsed subtitle packet from VobSub .sub file
*/
export interface SubtitlePacket {
/** Presentation timestamp in milliseconds */
pts: number
/** Display duration in milliseconds */
duration: number
/** Horizontal position in pixels */
x: number
/** Vertical position in pixels */
y: number
/** Image width in pixels */
width: number
/** Image height in pixels */
height: number
/** RLE-compressed image data */
rleData: Uint8Array
/** True if this is a forced subtitle */
forced: boolean
}
/**
* Parse subtitle packet from .sub file at given file position
* @param data - Binary .sub file data
* @param offset - Byte offset where packet starts
* @returns Parsed subtitle packet or null if invalid
* @example
* const subData = Bun.file('movie.sub').arrayBuffer()
* const packet = parseSubPacket(new Uint8Array(subData), 0x12345)
*/
export function parseSubPacket(data: Uint8Array, offset: number): SubtitlePacket | null {
const len = data.length
let pos = offset
// Find MPEG-PS packet start code (00 00 01 BA)
if (
pos + 3 < len &&
data[pos] === 0x00 &&
data[pos + 1] === 0x00 &&
data[pos + 2] === 0x01 &&
data[pos + 3] === 0xBA
) {
// Already aligned
} else {
while (pos < len - 4) {
if (data[pos] === 0x00 && data[pos + 1] === 0x00 && data[pos + 2] === 0x01 && data[pos + 3] === 0xBA) {
break
}
pos++
}
}
if (pos >= len - 14) {
return null
}
// Skip MPEG-PS pack header (14 bytes)
pos += 14
// Look for PES packet start code (00 00 01 BD - private stream 1)
if (pos + 4 > len || data[pos] !== 0x00 || data[pos + 1] !== 0x00 || data[pos + 2] !== 0x01 || data[pos + 3] !== 0xBD) {
return null
}
pos += 4
// PES packet length (2 bytes)
if (pos + 2 > len) return null
const pesLength = (data[pos] << 8) | data[pos + 1]
pos += 2
const pesStart = pos
const pesEnd = pesStart + pesLength
if (pesEnd > len) {
return null
}
// Skip PES header flags (2 bytes)
pos += 2
// PES header data length
if (pos >= len) return null
const pesHeaderDataLength = data[pos++]
// Extract PTS if present
let pts = 0
if (pesHeaderDataLength >= 5 && pos + 5 <= len) {
const ptsBits = data[pos]
if ((ptsBits & 0xF0) === 0x20 || (ptsBits & 0xF0) === 0x30) {
// PTS present
const pts32_30 = (data[pos] & 0x0E) >> 1
const pts29_15 = ((data[pos + 1] << 8) | data[pos + 2]) >> 1
const pts14_0 = ((data[pos + 3] << 8) | data[pos + 4]) >> 1
const ptsValue = (pts32_30 << 30) | (pts29_15 << 15) | pts14_0
pts = Math.floor(ptsValue / 90) // Convert 90kHz to milliseconds
}
}
pos += pesHeaderDataLength
// Subtitle stream ID (should be 0x20 for first subtitle stream)
if (pos >= len) return null
const streamId = data[pos++]
// Subtitle packet size
if (pos + 2 > len) return null
const subPacketSize = (data[pos] << 8) | data[pos + 1]
pos += 2
const subPacketStart = pos
const subPacketEnd = Math.min(subPacketStart + subPacketSize, pesEnd)
if (subPacketEnd > len) {
return null
}
// Parse control sequence to get dimensions and timing
// The control sequence typically starts after the RLE data
// For now, we'll search for it
let controlSeqOffset = subPacketStart
let controlInfo: ControlInfo | null = null
const packetLen = subPacketEnd - subPacketStart
if (packetLen >= 4) {
const sizeField = (data[subPacketStart] << 8) | data[subPacketStart + 1]
const offsetField = (data[subPacketStart + 2] << 8) | data[subPacketStart + 3]
if (offsetField >= 4 && offsetField < packetLen && sizeField >= offsetField) {
const candidate = subPacketStart + offsetField
const maybeInfo = parseControlSequenceFast(data, candidate, subPacketEnd)
?? parseControlSequence(data, candidate, subPacketEnd)
if (maybeInfo && (maybeInfo.width > 0 || maybeInfo.height > 0)) {
controlSeqOffset = candidate
controlInfo = maybeInfo
}
}
}
if (!controlInfo) {
const guessedOffset = findControlSequence(data, subPacketStart, subPacketEnd)
if (guessedOffset !== null) {
const maybeInfo = parseControlSequenceFast(data, guessedOffset, subPacketEnd)
?? parseControlSequence(data, guessedOffset, subPacketEnd)
if (maybeInfo && (maybeInfo.width > 0 || maybeInfo.height > 0)) {
controlSeqOffset = guessedOffset
controlInfo = maybeInfo
}
}
}
if (!controlInfo) {
// Fallback scan for control sequence by looking for common control commands
for (let searchPos = subPacketStart; searchPos < subPacketEnd - 10; searchPos++) {
const maybeInfo = parseControlSequence(data, searchPos, subPacketEnd)
if (maybeInfo && (maybeInfo.width > 0 || maybeInfo.height > 0)) {
controlSeqOffset = searchPos
controlInfo = maybeInfo
break
}
}
}
if (!controlInfo) {
// Couldn't find valid control sequence, return null
return null
}
// Extract RLE data (between data start and control sequence)
const rleStart = (controlInfo && packetLen >= 4 && controlSeqOffset > subPacketStart + 4)
? subPacketStart + 4
: subPacketStart
const rleEnd = controlSeqOffset
const rleData = data.subarray(rleStart, rleEnd)
return {
pts,
duration: controlInfo.duration,
x: controlInfo.x,
y: controlInfo.y,
width: controlInfo.width,
height: controlInfo.height,
rleData,
forced: controlInfo.forced,
}
}
interface ControlInfo {
duration: number
x: number
y: number
width: number
height: number
forced: boolean
}
function parseControlSequenceFast(data: Uint8Array, offset: number, end: number): ControlInfo | null {
const remaining = end - offset
if (remaining < 11) return null
if (data[offset] !== 0x05) return null
const pos = offset + 1
if (pos + 9 >= end) return null
const x1 = (data[pos] << 4) | (data[pos + 1] >> 4)
const x2 = ((data[pos + 1] & 0x0F) << 8) | data[pos + 2]
const y1 = (data[pos + 3] << 4) | (data[pos + 4] >> 4)
const y2 = ((data[pos + 4] & 0x0F) << 8) | data[pos + 5]
if (data[pos + 6] !== 0x02) return null
const stopTime = (data[pos + 7] << 8) | data[pos + 8]
const duration = Math.floor(stopTime / 90 * 1024)
const tail = data[pos + 9]
let forced = false
if (tail === 0xFF) {
forced = false
} else if (tail === 0x00) {
if (pos + 10 >= end || data[pos + 10] !== 0xFF) return null
forced = true
} else {
return null
}
return {
duration,
x: x1,
y: y1,
width: x2 - x1 + 1,
height: y2 - y1 + 1,
forced,
}
}
/**
* Find control sequence in subtitle packet
*/
function findControlSequence(data: Uint8Array, start: number, end: number): number | null {
// Control sequence typically starts after RLE data and near the end.
// Search backwards for an end-of-line marker close to the tail.
for (let pos = end - 2; pos >= start; pos--) {
if (data[pos] === 0x00 && data[pos + 1] === 0x00) {
return pos
}
}
// If not found with markers, use heuristic: control sequence is typically in last ~20 bytes.
if (end - start > 20) {
return end - 20
}
return start
}
/**
* Parse control sequence
*/
function parseControlSequence(data: Uint8Array, offset: number, end: number): ControlInfo | null {
let pos = offset
let duration = 0
let x = 0
let y = 0
let width = 0
let height = 0
let forced = false
while (pos < end) {
const cmd = data[pos++]
if (cmd === 0x00) {
forced = true
continue
}
if (cmd === 0x01) {
if (pos + 2 > end) break
pos += 2
continue
}
if (cmd === 0x02) {
if (pos + 2 > end) break
const stopTime = (data[pos] << 8) | data[pos + 1]
duration = Math.floor(stopTime / 90 * 1024)
pos += 2
continue
}
if (cmd === 0x03 || cmd === 0x04) {
if (pos + 2 > end) break
pos += 2
continue
}
if (cmd === 0x05) {
if (pos + 6 > end) break
const x1 = (data[pos] << 4) | (data[pos + 1] >> 4)
const x2 = ((data[pos + 1] & 0x0F) << 8) | data[pos + 2]
const y1 = (data[pos + 3] << 4) | (data[pos + 4] >> 4)
const y2 = ((data[pos + 4] & 0x0F) << 8) | data[pos + 5]
x = x1
y = y1
width = x2 - x1 + 1
height = y2 - y1 + 1
pos += 6
continue
}
if (cmd === 0x06) {
if (pos + 4 > end) break
pos += 4
continue
}
if (cmd === 0xFF) {
break
}
}
return {
duration,
x,
y,
width,
height,
forced,
}
}
/**
* Create .sub binary data from subtitle packets
* @param packets - Array of subtitle packets to encode
* @returns Binary .sub file data
* @example
* const packets = [...]
* const subData = createSubBinary(packets)
* Bun.write('output.sub', subData)
*/
export function createSubBinary(packets: SubtitlePacket[]): Uint8Array {
const chunks: Uint8Array[] = []
for (const packet of packets) {
const chunk = createSubPacketBinary(packet)
chunks.push(chunk)
}
// Calculate total size
const totalSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0)
const result = new Uint8Array(totalSize)
let offset = 0
for (const chunk of chunks) {
result.set(chunk, offset)
offset += chunk.length
}
return result
}
/**
* Create single subtitle packet binary
*/
function createSubPacketBinary(packet: SubtitlePacket): Uint8Array {
const controlSeq = createControlSequence(packet)
const controlOffset = 4 + packet.rleData.length
const subPacketSize = 4 + packet.rleData.length + controlSeq.length
// Estimate sizes
const pesHeaderSize = 3 + 5 // Flags + PTS
const pesPayloadSize = 1 + 2 + subPacketSize // Stream ID + size + data
const pesLength = pesHeaderSize + pesPayloadSize
const totalSize = 14 + 6 + pesLength // Pack header + PES header + payload
const data = new Uint8Array(totalSize)
let pos = 0
// MPEG-PS pack header (simplified)
data[pos++] = 0x00
data[pos++] = 0x00
data[pos++] = 0x01
data[pos++] = 0xBA
// SCR (6 bytes in MPEG-2 format)
const scr = packet.pts * 90
data[pos++] = 0x44 | ((scr >> 30) & 0x03)
data[pos++] = (scr >> 22) & 0xFF
data[pos++] = 0x04 | ((scr >> 14) & 0xFC)
data[pos++] = (scr >> 7) & 0xFF
data[pos++] = 0x04 | ((scr << 1) & 0xFC)
data[pos++] = 0x01 // SCR extension + marker
// Mux rate and stuffing (4 bytes total for MPEG-2)
data[pos++] = 0x01
data[pos++] = 0x89
data[pos++] = 0xC3
data[pos++] = 0xF8 // Stuffing length = 0
// PES packet header
data[pos++] = 0x00
data[pos++] = 0x00
data[pos++] = 0x01
data[pos++] = 0xBD // Private stream 1
// PES length
data[pos++] = (pesLength >> 8) & 0xFF
data[pos++] = pesLength & 0xFF
// PES flags
data[pos++] = 0x80 // Original/copy
data[pos++] = 0x80 // PTS present
// PES header data length
data[pos++] = 5 // PTS length
// PTS
const ptsValue = packet.pts * 90
data[pos++] = 0x21 | ((ptsValue >> 29) & 0x0E)
data[pos++] = (ptsValue >> 22) & 0xFF
data[pos++] = 0x01 | ((ptsValue >> 14) & 0xFE)
data[pos++] = (ptsValue >> 7) & 0xFF
data[pos++] = 0x01 | ((ptsValue << 1) & 0xFE)
// Subtitle stream ID
data[pos++] = 0x20
// Subtitle packet size
data[pos++] = (subPacketSize >> 8) & 0xFF
data[pos++] = subPacketSize & 0xFF
// Subpicture header (size + control sequence offset)
data[pos++] = (subPacketSize >> 8) & 0xFF
data[pos++] = subPacketSize & 0xFF
data[pos++] = (controlOffset >> 8) & 0xFF
data[pos++] = controlOffset & 0xFF
// RLE data
data.set(packet.rleData, pos)
pos += packet.rleData.length
// Control sequence
data.set(controlSeq, pos)
return data
}
/**
* Create control sequence for subtitle packet
*/
function createControlSequence(packet: SubtitlePacket): Uint8Array {
const seq: number[] = []
// Coordinates (cmd 0x05)
const x1 = packet.x
const x2 = packet.x + packet.width - 1
const y1 = packet.y
const y2 = packet.y + packet.height - 1
seq.push(0x05)
seq.push((x1 >> 4) & 0xFF)
seq.push(((x1 & 0x0F) << 4) | ((x2 >> 8) & 0x0F))
seq.push(x2 & 0xFF)
seq.push((y1 >> 4) & 0xFF)
seq.push(((y1 & 0x0F) << 4) | ((y2 >> 8) & 0x0F))
seq.push(y2 & 0xFF)
// Stop display (cmd 0x02)
const stopTime = Math.floor(packet.duration * 90 / 1024)
seq.push(0x02)
seq.push((stopTime >> 8) & 0xFF)
seq.push(stopTime & 0xFF)
// Force flag (cmd 0x00)
if (packet.forced) {
seq.push(0x00)
}
// End of sequence
seq.push(0xFF)
return new Uint8Array(seq)
}