bddvdsubreader
Version:
Parse VobSub and PGS subtitles with NodeJS
470 lines (394 loc) • 19 kB
JavaScript
import fs from 'node:fs';
import BufferReader from './module.buf.js';
import RGBAImage from './module.spu.js';
const PS_PACK_SIZE = 0x800;
const PTS_CLOCK = 90;
const MAX_DELAY = 5000;
const MS_DELAY = 24;
class VobSubParser {
constructor(noIndex){
this._noIndex = noIndex;
}
openFile(filePath){
const targetPath = filePath.replace(/\.(sub|idx)$/i, '');
const idxPath = targetPath + '.idx';
const subPath = targetPath + '.sub';
if(!fs.existsSync(subPath)) throw new Error('File ".sub" not exists!');
const index = this._openIdxFile(idxPath);
const subFile = fs.readFileSync(subPath);
const subSize = fs.statSync(subPath).size;
const subtitles = [];
if (subSize % PS_PACK_SIZE > 0) throw new Error('File ".sub" bad file size');
const reader = new BufferReader(subFile);
const frames = [];
while(reader.remaining() / PS_PACK_SIZE > 0){
const vobPack = new VobPackReader(frames.length, index, reader);
const spuPack = new SPUPackReader(frames.length, index, vobPack);
if(!index.languages.has(spuPack.track_index)){
index.languages.set(spuPack.track_index, { id: 'un', title: 'undefined' });
}
if(frames.length > 0 && frames.at(-1).end === null && spuPack.track_index === frames.at(-1).track_index){
frames.at(-1).end = spuPack.pts - MS_DELAY;
}
if(frames.length > 0 && frames.at(-1).end === null){
frames.at(-1).end = frames.at(-1).pts + MAX_DELAY;
}
frames.push(spuPack);
}
return { tracks: index.languages, frames };
}
_openIdxText(idx){
return new Index(idx);
}
_openIdxFile(idxPath){
if(fs.existsSync(idxPath) && !this._noIndex){
const idx = fs.readFileSync(idxPath, 'utf-8');
return new Index(idx);
}
return new Index('');
}
}
class VobPackReader {
constructor(packId, index, reader) {
this.data = {
track_index: null,
forced: false,
pts: null,
end: null,
spu: null,
};
const paragraphs = index.paragraphs;
let spuData = Buffer.alloc(0);
if(paragraphs.has(packId)){
const cur = paragraphs.get(packId);
if(cur.filepos !== reader.tell()) throw new Error('[BAD] FilePos Index Value!');
this.data.track_index = cur.track_index;
this.data.pts = cur.timestamp;
}
while(true){
const startOffset = reader.tell();
if(reader.remaining() / PS_PACK_SIZE < 1) throw new Error('[BAD] Remaining Buffer Size!');
if(reader.remaining() % PS_PACK_SIZE > 0) throw new Error('[BAD] Offset Buffer Position!');
// PS Start
const psStartCode = reader.readUInt24BE();
const psPackId = reader.readUInt8();
if(psStartCode !== 0x1 || psPackId !== 0xba){
throw new Error('[BAD] PS Packet Header');
}
// System Clock Reference
reader.skip(6);
// Multiplexer Rate
reader.skip(3);
// Reserved and Stuffing Length (5bit + 3bit)
const psStuffingLength = reader.readUInt8() & 0b111;
reader.skip(psStuffingLength);
// PES Start
const pesStartCode = reader.readUInt24BE();
const pesPackId = reader.readUInt8();
if(pesStartCode !== 0x1 || pesPackId !== 0xbd){
throw new Error('[BAD] PES Packet Header');
}
// PES Pack length
const pesPacketLength = reader.readUInt16BE();
const nextOffset = reader.tell() + pesPacketLength;
// PES Header Main Data 0b10XXXXXX 0bXXXXXXXX
const pesHeaderFlags = reader.readUInt16BE();
const pstDtsFlags = (pesHeaderFlags >> 6) & 0b11;
const isHasPts = pstDtsFlags == 0b10 || pstDtsFlags == 0b11;
// PES Header Data length
const pesHeaderDataLength = reader.readUInt8();
const pesHeaderData = reader.readBytes(pesHeaderDataLength);
if(pesHeaderDataLength >= 5 && isHasPts){
const ptsDataBuf = pesHeaderData.subarray(0, 5);
const ptsValue = this._readPtsFromBuf(ptsDataBuf);
if(this.data.pts === null) this.data.pts = ptsValue;
if(this.data.pts >= 0 && ptsValue !== this.data.pts) throw new Error('[BAD] PTS Value');
}
const track_index = reader.readUInt8();
if (track_index < 0x20 || track_index > 0x40){
throw new Error('[BAD] Track ID!');
}
if(this.data.track_index === null) this.data.track_index = track_index - 0x20;
if(track_index - 0x20 !== this.data.track_index) throw new Error('[BAD] Track ID!');
// save spuBuffer
const spuChunkLength = (nextOffset - startOffset) - (reader.tell() - startOffset);
const chunk = reader.readBytes(spuChunkLength);
spuData = Buffer.concat([spuData, chunk]);
reader.seek(startOffset + 0x800);
// check sizes
if(spuData.readUInt16BE() < spuData.length) throw new Error('[BAD] SPU Buffer Size');
// END
if(spuData.readUInt16BE() === spuData.length) break;
}
this.data.spu = spuData;
return this.data;
}
_readPtsFromBuf(buf) {
const [b0, b1, b2, b3, b4] = buf;
const pts = (
(BigInt(b0 & 0x0E) << 29n) | // PTS[32..30]
(BigInt(b1) << 22n) | // PTS[29..22]
(BigInt(b2 & 0xFE) << 14n) | // PTS[21..15]
(BigInt(b3) << 7n) | // PTS[14..7]
(BigInt(b4 & 0xFE) >> 1n) // PTS[6..0]
);
return Number(pts) / PTS_CLOCK;
}
}
class SPUPackReader {
constructor(packId, index, pack) {
const spuBuffer = new BufferReader(pack.spu);
const spuBufSize = spuBuffer.readUInt16BE();
delete pack.spu;
const ctrlOffset = spuBuffer.readUInt16BE();
spuBuffer.seek(ctrlOffset);
const ctrl = [];
while(spuBuffer.tell() < spuBufSize){
const ctrlData = { delay: 0, commands: Buffer.alloc(0) };
ctrlData.delay = Math.round((spuBuffer.readUInt16BE() << 10) / PTS_CLOCK);
const ctrlOffset = spuBuffer.readUInt16BE();
if(ctrlOffset === spuBuffer.tell() - 4){
ctrlData.commands = new BufferReader(spuBuffer.buffer.subarray(spuBuffer.tell()));
ctrl.push(ctrlData);
break;
}
else{
ctrlData.commands = new BufferReader(spuBuffer.buffer.subarray(spuBuffer.tell(), ctrlOffset));
spuBuffer.seek(ctrlOffset);
ctrl.push(ctrlData);
}
}
if(ctrl.length > 2){
throw new Error('[BAD] Too many command sequences');
}
// set temp data
const tdata = {};
let PXDtf, PXDbf;
// parse commands
let ctrlIndex = 0;
while (ctrlIndex < ctrl.length) {
const curCtrl = ctrl[ctrlIndex];
const cmdBuf = curCtrl.commands;
while(cmdBuf.remaining() > 0){
const cmd = cmdBuf.readUInt8();
switch (cmd){
case 0x00: // FSTA_DSP
pack.forced = true;
break;
case 0x01: // STA_DSP
if(pack.pts === null || pack.pts >= 0 && curCtrl.delay > 0){
throw new Error('BAD COMMAND: Start Display!');
}
pack.pts += curCtrl.delay;
break;
case 0x02: // STP_DSP
if(pack.pts === null || pack.end !== null && pack.end > 0){
throw new Error('BAD COMMAND: End Display!');
}
pack.end = pack.pts + curCtrl.delay;
break;
case 0x03: // SET_COLOR
// e2 e1 p b
// 0 background (B)
// 1 pattern (P)
// 2 emphasis 1 (E1)
// 3 emphasis 1 (E2)
const cmd3data = cmdBuf.readUInt16BE();
// => 2 8 2 0
tdata.palette = {
b: index.params.palette[cmd3data & 0xF],
p: index.params.palette[cmd3data >> 4 & 0xF],
e1: index.params.palette[cmd3data >> 8 & 0xF],
e2: index.params.palette[cmd3data >> 12 & 0xF],
};
break;
case 0x04: // SET_CONTR
const cmd4data = cmdBuf.readUInt16BE();
tdata.alpha = {
b: cmd4data & 0xF,
p: cmd4data >> 4 & 0xF,
e1: cmd4data >> 8 & 0xF,
e2: cmd4data >> 12 & 0xF,
};
break;
case 0x05: // SET_DAREA
// sx sx sx ex ex ex sy sy sy ey ey ey
// sx = starting X coordinate
// ex = ending X coordinate
// sy = starting Y coordinate
// ey = ending Y coordinate
const x = cmdBuf.readUInt24BE();
const y = cmdBuf.readUInt24BE();
tdata.pos = {
left: x >> 12,
right: x & 0xFFF,
top: y >> 12,
bottom: y & 0xFFF,
};
if(tdata.pos.right < tdata.pos.left || tdata.pos.bottom < tdata.pos.top){
throw new Error('[BAD] Invalid Bounding Box');
}
tdata.size = {};
tdata.size.width = tdata.pos.right - tdata.pos.left + 1;
tdata.size.height = tdata.pos.bottom - tdata.pos.top + 1;
break;
case 0x06: // SET_DSPXA
const PXDtfOffset = cmdBuf.readUInt16BE();
const PXDbfOffset = cmdBuf.readUInt16BE();
PXDtf = spuBuffer.buffer.subarray(PXDtfOffset);
PXDbf = spuBuffer.buffer.subarray(PXDbfOffset);
break;
case 0x07: // CHG_COLCON
throw new Error('[BAD] COMMAND 0x07 NOT IMPLEMENTED!');
break;
case 0xFF: // CMD_END
cmdBuf.skip(cmdBuf.remaining())
break;
default: // CMD_UNK
throw new Error('[BAD] UNKNOWN COMMAND!');
}
}
// end command
ctrlIndex++;
}
pack.width = tdata.size.width;
pack.height = tdata.size.height;
pack.rgba = new RGBAImage(
tdata.size.width,
tdata.size.height,
tdata.palette,
tdata.alpha,
PXDtf,
PXDbf,
);
return pack;
}
}
class Index {
constructor(lines) {
this.params = {};
this.languages = new Map();
this.paragraphs = new Map();
this._getLanguageName = new Intl.DisplayNames(['en'], {type: 'language', style: 'short'});
this._parseLines(lines.split('\n'));
if(!this.params.palette){
this._parseLines([
'palette:' // From FFMPEG
+ ' 000000, 0000ff, 00ff00, ff0000,'
+ ' ffff00, ff00ff, 00ffff, ffffff,'
+ ' 808000, 8080ff, 800080, 80ff80,'
+ ' 008080, ff8080, 555555, aaaaaa'
]);
}
}
_parseLines(lines) {
const sizeLP = /^size\: (?<width>\d+)x(?<height>\d+)$/;
const originLP = /^org\: (?<x>\d+), (?<y>\d+)$/;
const scaleLP = /^scale\: (?<horizontal>\d+)%, (?<vertical>\d+)%$/;
const alphaLP = /^alpha\: (?<value>\d+)%$/;
const fadeLP = /^fadein\/out\: (?<fadein>\d+), (?<fadeout>\d+)$/;
const timeCodeLP = /^timestamp\: (?<h>\d+):(?<m>\d+):(?<s>\d+):(?<ms>\d+), filepos: (?<filepos>[\da-fA-F]+)$/;
let track_index = -1;
lines.forEach(line => {
line = line.trim();
if(sizeLP.test(line)){
const [width, height] = Object.values(line.match(sizeLP).groups).map(v => parseInt(v));
this.params.size = { width, height };
}
if(originLP.test(line)){
const [x, y] = Object.values(line.match(originLP).groups).map(v => parseInt(v));
this.params.origin = { x, y };
}
if(scaleLP.test(line)){
const [horizontal, vertical] = Object.values(line.match(scaleLP).groups).map(v => parseFloat(v) / 100);
this.params.scale = { horizontal, vertical };
}
if(alphaLP.test(line)){
const [ value ] = Object.values(line.match(alphaLP).groups).map(v => parseFloat(v) / 100);
this.params.alpha = value;
}
if(/^smooth\:/i.test(line) && line.length > 8){
const value = line.substring('smooth:'.length + 1).toUpperCase();
if (value == 'OLD' || value == '2') this.params.smooth = 2;
if (value == 'ON' || value == '1') this.params.smooth = 1;
if (value == 'OFF' || value == '0') this.params.smooth = 0;
}
if(fadeLP.test(line)){
const [ fadein, fadeout ] = Object.values(line.match(fadeLP).groups).map(v => parseInt(v));
this.params.fade = { fadein, fadeout };
}
if(/^align\: (OFF|ON) at (LEFT|CENTER|RIGHT) (TOP|CENTER|BOTTOM)$/i.test(line)){
this.params.align = {};
const value = line.substring('align:'.length + 1).toUpperCase().split(/ at /i).map(v => v.trim().split(' '));
const [ align, alignh, alignv ] = [ value[0][0], value[1][0], value[1][1] ];
if (align == 'ON' || align == '1') this.params.align.on = true;
if (align == 'OFF' || align == '0') this.params.align.on = false;
if (alignh == 'LEFT' ) this.params.align.horizontal = 0;
if (alignh == 'CENTER') this.params.align.horizontal = 1;
if (alignh == 'RIGHT' ) this.params.align.horizontal = 2;
if (alignv == 'TOP' ) this.params.align.vertical = 0;
if (alignv == 'CENTER') this.params.align.vertical = 1;
if (alignv == 'BOTTOM') this.params.align.vertical = 2;
}
if(/^time offset\: (?<is_negative>-)?(?<offset>\d+)$/i.test(line)){
this.params.time_offset = 0; // ignore for now...
}
if(/^forced subs\: (OFF|ON|0|1)$/i.test(line)){
const value = line.substring('forced subs:'.length + 1).toUpperCase();
if (value == 'ON' || value == '1') this.params.forced_subs = true;
if (value == 'OFF' || value == '0') this.params.forced_subs = false;
}
if(/^palette\:/i.test(line) && line.length > 10){
this.params.palette = [];
const colors = line.substring('palette:'.length + 1).split(/[,\s]+/).filter(Boolean);
colors.forEach(hex => this.params.palette.push(this._hexToColor(hex)));
}
if(/^id\:/i.test(line) && line.length > 4){
const parts = line.split(/[:,\s]+/).filter(Boolean);
const language_id = parts[1];
const language_name = this._getLanguageName.of(language_id);
if (parts.length > 3 && parts[2].toLowerCase() === 'index') {
const txt_lang_index = parseInt(parts[3], 10);
if (!isNaN(txt_lang_index)){
track_index = txt_lang_index;
}
else{
track_index++;
}
}
this.languages.set(track_index, { id: language_id, title: language_name });
}
if(timeCodeLP.test(line)){
const [ h, m, s, ms, filepos ] = Object.entries(line.match(timeCodeLP).groups).map(v => {
return parseInt(v[1], v[0] == 'filepos' ? 16 : 10);
});
const timestamp = (h * 3600 + m * 60 + s) * 1000 + ms;
this.paragraphs.set(this.paragraphs.size, { track_index, timestamp, filepos });
}
});
}
_hexToColor(hex) {
hex = hex.replace(/^#/, '').trim();
if (hex.length === 6) {
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
return { r, g, b };
}
else if (hex.length === 8) {
const a = parseInt(hex.substr(0, 2), 16);
const r = parseInt(hex.substr(2, 2), 16);
const g = parseInt(hex.substr(4, 2), 16);
const b = parseInt(hex.substr(6, 2), 16);
return { r, g, b, a };
}
return { r: 255, g: 255, b: 255 };
}
}
export default class VobSubReader {
constructor(vobSubPath, noIndex = false){
const vobSubParser = new VobSubParser(noIndex);
const data = vobSubParser.openFile(vobSubPath);
return data;
}
}