High-performance WASM renderer for graphical subtitles (PGS and VobSub)
npm install libbitsubHigh-performance WASM renderer for graphical subtitles (PGS and VobSub), written in Rust.
Started as a fork of Arcus92's libpgs-js, this project is re-engineered to maximize performance and extend functionality to VobSub, which was not supported by the original library. It remains fully backward compatible (only for PGS - obliviously). Special thanks to the original project for the inspiration!
- PGS (Blu-ray) subtitle parsing and rendering
- VobSub (DVD) subtitle parsing and rendering
- WebGPU rendering GPU-accelerated rendering with automatic Canvas2D fallback
- High-performance Rust-based rendering engine compiled to WebAssembly
- Zero-copy data transfer between JS and WASM where possible
- Caching for decoded bitmaps to optimize repeated rendering
- TypeScript support with full type definitions
https://gist.github.com/user-attachments/assets/55ac8e11-1964-4fb9-923e-dcac82dc7703
https://gist.github.com/user-attachments/assets/a89ae9fe-23e4-4bc3-8cad-16a3f0fea665
npm / bun
``bash`
npm install libbitsubor
bun add libbitsub
JSR (Deno)
`bash`
deno add jsr:@altq/libbitsub
For best performance with large subtitle files, copy the WASM files to your public folder so Web Workers can access them:
`bash`For Next.js, Vite, or similar frameworks
mkdir -p public/libbitsub
cp node_modules/libbitsub/pkg/libbitsub_bg.wasm public/libbitsub/
cp node_modules/libbitsub/pkg/libbitsub.js public/libbitsub/
This enables off-main-thread parsing which prevents UI freezing when loading large PGS files.
To build from source, you need:
`bash`Install wasm-pack
cargo install wasm-pack
`bashBuild WASM module and TypeScript wrapper
bun run build
Usage
$3
Before using any renderer, you must initialize the WASM module:
`typescript
import { initWasm } from 'libbitsub'// Initialize WASM (do this once at app startup)
await initWasm()
`High-Level API (Video Integration)
The high-level API automatically handles video synchronization, canvas overlay, and subtitle fetching.
$3
`typescript
import { PgsRenderer } from 'libbitsub'// Create renderer with video element (URL-based loading)
const renderer = new PgsRenderer({
video: videoElement,
subUrl: '/subtitles/movie.sup',
workerUrl: '/libbitsub.js', // Optional, kept for API compatibility
// Lifecycle callbacks (optional)
onLoading: () => console.log('Loading subtitles...'),
onLoaded: () => console.log('Subtitles loaded!'),
onError: (error) => console.error('Failed to load:', error)
})
// Or load directly from ArrayBuffer
const response = await fetch('/subtitles/movie.sup')
const subtitleData = await response.arrayBuffer()
const renderer = new PgsRenderer({
video: videoElement,
subContent: subtitleData, // Load directly from ArrayBuffer
onLoading: () => console.log('Loading subtitles...'),
onLoaded: () => console.log('Subtitles loaded!'),
onError: (error) => console.error('Failed to load:', error)
})
// The renderer automatically:
// - Fetches the subtitle file (if using subUrl) or uses provided ArrayBuffer
// - Creates a canvas overlay on the video
// - Syncs rendering with video playback
// - Handles resize events
// When done:
renderer.dispose()
`$3
`typescript
import { VobSubRenderer } from 'libbitsub'// Create renderer with video element (URL-based loading)
const renderer = new VobSubRenderer({
video: videoElement,
subUrl: '/subtitles/movie.sub',
idxUrl: '/subtitles/movie.idx', // Optional, defaults to .sub path with .idx extension
workerUrl: '/libbitsub.js', // Optional
// Lifecycle callbacks (optional)
onLoading: () => setIsLoading(true),
onLoaded: () => setIsLoading(false),
onError: (error) => {
setIsLoading(false)
console.error('Subtitle error:', error)
}
})
// Or load directly from ArrayBuffer
const [subResponse, idxResponse] = await Promise.all([fetch('/subtitles/movie.sub'), fetch('/subtitles/movie.idx')])
const subData = await subResponse.arrayBuffer()
const idxData = await idxResponse.text()
const renderer = new VobSubRenderer({
video: videoElement,
subContent: subData, // Load .sub directly from ArrayBuffer
idxContent: idxData, // Load .idx directly from string
onLoading: () => setIsLoading(true),
onLoaded: () => setIsLoading(false),
onError: (error) => {
setIsLoading(false)
console.error('Subtitle error:', error)
}
})
// When done:
renderer.dispose()
`$3
Both
PgsRenderer and VobSubRenderer support real-time customization of subtitle size and position:`typescript
// Get current settings
const settings = renderer.getDisplaySettings()
console.log(settings)
// Output: { scale: 1.0, verticalOffset: 0 }// Update settings
renderer.setDisplaySettings({
scale: 1.2, // 1.2 = 120% size
verticalOffset: -10 // -10% (move up 10% of video height)
})
// Reset to defaults
renderer.resetDisplaySettings()
`$3
VobSub subtitles often exhibit banding artifacts due to their limited 4-color palette. libbitsub includes a neo_f3kdb-style debanding filter that smooths color transitions:
`typescript
import { VobSubRenderer } from 'libbitsub'const renderer = new VobSubRenderer({
video: videoElement,
subUrl: '/subtitles/movie.sub'
})
// Debanding is enabled by default; call to disable if needed
// renderer.setDebandEnabled(false)
// Fine-tune debanding parameters
renderer.setDebandThreshold(64.0) // Higher = more aggressive smoothing
renderer.setDebandRange(15) // Pixel radius for sampling
// Check if debanding is active
console.log(renderer.debandEnabled) // true
`Low-Level API:
`typescript
import { VobSubParserLowLevel } from 'libbitsub'const parser = new VobSubParserLowLevel()
parser.loadFromData(idxContent, subData)
// Configure debanding before rendering
parser.setDebandEnabled(true)
parser.setDebandThreshold(48.0)
parser.setDebandRange(12)
// Rendered frames will have debanding applied
const frame = parser.renderAtIndex(0)
`Debanding Settings:
| Property | Type | Default | Range | Description |
| ----------- | ------- | ------- | --------- | ------------------------------------------------ |
|
enabled | boolean | true | - | Enable/disable the debanding filter |
| threshold | number | 64.0 | 0.0-255.0 | Difference threshold; higher = more smoothing |
| range | number | 15 | 1-64 | Sample radius in pixels; higher = wider sampling |Notes:
- Debanding is applied post-decode on the RGBA output
- Uses cross-shaped sampling with factor-based blending (neo_f3kdb sample_mode 6 style)
- Transparent pixels are skipped for performance
- Deterministic output (same input = same output)
Settings Reference:
-
scale (number): Scale factor for subtitles.
- 1.0 = 100% (Original size)
- 0.5 = 50%
- 2.0 = 200%
- Range: 0.1 to 3.0-
verticalOffset (number): Vertical position offset as a percentage of video height.
- 0 = Original position
- Negative values move up (e.g., -10 moves up by 10% of height)
- Positive values move down (e.g., 10 moves down by 10% of height)
- Range: -50 to 50$3
Both
PgsRenderer and VobSubRenderer provide real-time performance metrics:`typescript
// Get performance statistics
const stats = renderer.getStats()
console.log(stats)
// Output:
// {
// framesRendered: 120,
// framesDropped: 2,
// avgRenderTime: 1.45,
// maxRenderTime: 8.32,
// minRenderTime: 0.12,
// lastRenderTime: 1.23,
// renderFps: 60,
// usingWorker: true,
// cachedFrames: 5,
// pendingRenders: 0,
// totalEntries: 847,
// currentIndex: 42
// }// Example: Display stats in a debug overlay
setInterval(() => {
const stats = renderer.getStats()
debugOverlay.textContent =
}, 1000)
`Stats Reference:
| Property | Type | Description |
| ---------------- | ------- | -------------------------------------------------------------- |
|
framesRendered | number | Total frames rendered since initialization |
| framesDropped | number | Frames dropped due to slow rendering (>16.67ms) |
| avgRenderTime | number | Average render time in milliseconds (rolling 60-sample window) |
| maxRenderTime | number | Maximum render time in milliseconds |
| minRenderTime | number | Minimum render time in milliseconds |
| lastRenderTime | number | Most recent render time in milliseconds |
| renderFps | number | Current renders per second (based on last 1 second) |
| usingWorker | boolean | Whether rendering is using Web Worker (off-main-thread) |
| cachedFrames | number | Number of decoded frames currently cached |
| pendingRenders | number | Number of frames currently being decoded asynchronously |
| totalEntries | number | Total subtitle entries/display sets in the loaded file |
| currentIndex | number | Index of the currently displayed subtitle |$3
libbitsub automatically uses WebGPU for GPU-accelerated rendering when available, with automatic fallback to Canvas2D:
`typescript
import { PgsRenderer, isWebGPUSupported } from 'libbitsub'// Check WebGPU support
if (isWebGPUSupported()) {
console.log('WebGPU available - GPU-accelerated rendering enabled')
}
// Configure WebGPU preference
const renderer = new PgsRenderer({
video: videoElement,
subUrl: '/subtitles/movie.sup',
preferWebGPU: true, // default: true
onWebGPUFallback: () => console.log('Fell back to Canvas2D')
})
`Options:
-
preferWebGPU (boolean): Enable WebGPU rendering if available. Default: true
- onWebGPUFallback (function): Callback when WebGPU is unavailable and falls back to Canvas2DLow-Level API (Programmatic Use)
For more control over rendering, use the low-level parsers directly.
$3
`typescript
import { initWasm, PgsParser } from 'libbitsub'await initWasm()
const parser = new PgsParser()
// Load PGS data from a .sup file
const response = await fetch('subtitles.sup')
const data = new Uint8Array(await response.arrayBuffer())
parser.load(data)
// Get timestamps
const timestamps = parser.getTimestamps() // Float64Array in milliseconds
// Render at a specific time
const subtitleData = parser.renderAtTimestamp(currentTimeInSeconds)
if (subtitleData) {
for (const comp of subtitleData.compositionData) {
ctx.putImageData(comp.pixelData, comp.x, comp.y)
}
}
// Clean up
parser.dispose()
`$3
`typescript
import { initWasm, VobSubParserLowLevel } from 'libbitsub'await initWasm()
const parser = new VobSubParserLowLevel()
// Load from IDX + SUB files
const idxResponse = await fetch('subtitles.idx')
const idxContent = await idxResponse.text()
const subResponse = await fetch('subtitles.sub')
const subData = new Uint8Array(await subResponse.arrayBuffer())
parser.loadFromData(idxContent, subData)
// Or load from SUB file only
// parser.loadFromSubOnly(subData);
// Render
const subtitleData = parser.renderAtTimestamp(currentTimeInSeconds)
if (subtitleData) {
for (const comp of subtitleData.compositionData) {
ctx.putImageData(comp.pixelData, comp.x, comp.y)
}
}
parser.dispose()
`$3
For handling both formats with a single API:
`typescript
import { initWasm, UnifiedSubtitleParser } from 'libbitsub'await initWasm()
const parser = new UnifiedSubtitleParser()
// Load PGS
parser.loadPgs(pgsData)
// Or load VobSub
// parser.loadVobSub(idxContent, subData);
console.log(parser.format) // 'pgs' or 'vobsub'
const subtitleData = parser.renderAtTimestamp(time)
// ... render to canvas
parser.dispose()
`API Reference
$3
####
PgsRenderer-
constructor(options: VideoSubtitleOptions) - Create video-integrated PGS renderer
- getDisplaySettings(): SubtitleDisplaySettings - Get current display settings
- setDisplaySettings(settings: Partial - Update display settings
- resetDisplaySettings(): void - Reset display settings to defaults
- getStats(): SubtitleRendererStats - Get performance statistics
- dispose(): void - Clean up all resources####
VobSubRenderer-
constructor(options: VideoVobSubOptions) - Create video-integrated VobSub renderer
- getDisplaySettings(): SubtitleDisplaySettings - Get current display settings
- setDisplaySettings(settings: Partial - Update display settings
- resetDisplaySettings(): void - Reset display settings to defaults
- getStats(): SubtitleRendererStats - Get performance statistics
- setDebandEnabled(enabled: boolean): void - Enable/disable debanding filter
- setDebandThreshold(threshold: number): void - Set debanding threshold (0.0-255.0)
- setDebandRange(range: number): void - Set debanding sample range (1-64)
- debandEnabled: boolean - Check if debanding is enabled
- dispose(): void - Clean up all resources$3
####
PgsParser-
load(data: Uint8Array): number - Load PGS data, returns display set count
- getTimestamps(): Float64Array - Get all timestamps in milliseconds
- count: number - Number of display sets
- findIndexAtTimestamp(timeSeconds: number): number - Find index for timestamp
- renderAtIndex(index: number): SubtitleData | undefined - Render at index
- renderAtTimestamp(timeSeconds: number): SubtitleData | undefined - Render at time
- clearCache(): void - Clear decoded bitmap cache
- dispose(): void - Release resources####
VobSubParserLowLevel-
loadFromData(idxContent: string, subData: Uint8Array): void - Load IDX + SUB
- loadFromSubOnly(subData: Uint8Array): void - Load SUB only
- setDebandEnabled(enabled: boolean): void - Enable/disable debanding filter
- setDebandThreshold(threshold: number): void - Set debanding threshold (0.0-255.0)
- setDebandRange(range: number): void - Set debanding sample range (1-64)
- debandEnabled: boolean - Check if debanding is enabled
- Same rendering methods as PgsParser####
UnifiedSubtitleParser-
loadPgs(data: Uint8Array): number - Load PGS data
- loadVobSub(idxContent: string, subData: Uint8Array): void - Load VobSub
- loadVobSubOnly(subData: Uint8Array): void - Load SUB only
- format: 'pgs' | 'vobsub' | null - Current format
- Same rendering methods as above$3
####
VideoSubtitleOptions`typescript
interface VideoSubtitleOptions {
video: HTMLVideoElement // Video element to sync with
subUrl?: string // URL to subtitle file (provide this OR subContent)
subContent?: ArrayBuffer // Direct subtitle content (provide this OR subUrl)
workerUrl?: string // Worker URL (for API compatibility)
preferWebGPU?: boolean // Prefer WebGPU renderer if available (default: true)
onLoading?: () => void // Called when subtitle loading starts
onLoaded?: () => void // Called when subtitle loading completes
onError?: (error: Error) => void // Called when subtitle loading fails
onWebGPUFallback?: () => void // Called when WebGPU is unavailable
}
`####
VideoVobSubOptions`typescript
interface VideoVobSubOptions extends VideoSubtitleOptions {
idxUrl?: string // URL to .idx file (optional, defaults to subUrl with .idx extension)
idxContent?: string // Direct .idx content (provide this OR idxUrl)
}
`####
SubtitleDisplaySettings`typescript
interface SubtitleDisplaySettings {
// Scale factor (1.0 = 100%, 0.5 = 50%, 2.0 = 200%)
scale: number
// Vertical offset as % of video height (-50 to 50)
verticalOffset: number
}
`####
SubtitleRendererStats`typescript
interface SubtitleRendererStats {
framesRendered: number // Total frames rendered since initialization
framesDropped: number // Frames dropped due to slow rendering
avgRenderTime: number // Average render time in milliseconds
maxRenderTime: number // Maximum render time in milliseconds
minRenderTime: number // Minimum render time in milliseconds
lastRenderTime: number // Last render time in milliseconds
renderFps: number // Current FPS (renders per second)
usingWorker: boolean // Whether rendering is using web worker
cachedFrames: number // Number of cached frames
pendingRenders: number // Number of pending renders
totalEntries: number // Total subtitle entries/display sets
currentIndex: number // Current subtitle index being displayed
}
`####
SubtitleData`typescript
interface SubtitleData {
width: number // Screen width
height: number // Screen height
compositionData: SubtitleCompositionData[]
}interface SubtitleCompositionData {
pixelData: ImageData // RGBA pixel data
x: number // X position
y: number // Y position
}
``Licensed under either of
- Apache License, Version 2.0
- MIT license