libbitsub 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +407 -378
  2. package/dist/index.d.ts +2 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/ts/parsers.d.ts +18 -1
  7. package/dist/ts/parsers.d.ts.map +1 -1
  8. package/dist/ts/parsers.js +151 -0
  9. package/dist/ts/parsers.js.map +1 -1
  10. package/dist/ts/renderers.d.ts +47 -4
  11. package/dist/ts/renderers.d.ts.map +1 -1
  12. package/dist/ts/renderers.js +391 -87
  13. package/dist/ts/renderers.js.map +1 -1
  14. package/dist/ts/types.d.ts +144 -0
  15. package/dist/ts/types.d.ts.map +1 -1
  16. package/dist/ts/utils.d.ts +11 -1
  17. package/dist/ts/utils.d.ts.map +1 -1
  18. package/dist/ts/utils.js +100 -1
  19. package/dist/ts/utils.js.map +1 -1
  20. package/dist/ts/wasm.js.map +1 -1
  21. package/dist/ts/webgl2-renderer.d.ts +2 -1
  22. package/dist/ts/webgl2-renderer.d.ts.map +1 -1
  23. package/dist/ts/webgl2-renderer.js +10 -7
  24. package/dist/ts/webgl2-renderer.js.map +1 -1
  25. package/dist/ts/webgpu-renderer.d.ts +4 -1
  26. package/dist/ts/webgpu-renderer.d.ts.map +1 -1
  27. package/dist/ts/webgpu-renderer.js +82 -32
  28. package/dist/ts/webgpu-renderer.js.map +1 -1
  29. package/dist/ts/worker.d.ts.map +1 -1
  30. package/dist/ts/worker.js +145 -87
  31. package/dist/ts/worker.js.map +1 -1
  32. package/dist/wrapper.d.ts +3 -2
  33. package/dist/wrapper.d.ts.map +1 -1
  34. package/dist/wrapper.js +3 -1
  35. package/dist/wrapper.js.map +1 -1
  36. package/package.json +3 -2
  37. package/pkg/README.md +407 -378
  38. package/pkg/libbitsub.d.ts +121 -1
  39. package/pkg/libbitsub.js +251 -15
  40. package/pkg/libbitsub_bg.wasm +0 -0
  41. package/pkg/libbitsub_bg.wasm.d.ts +25 -1
  42. package/pkg/package.json +1 -1
  43. package/src/wrapper.ts +14 -1
package/README.md CHANGED
@@ -2,33 +2,35 @@
2
2
 
3
3
  High-performance WASM renderer for graphical subtitles (PGS and VobSub), written in Rust.
4
4
 
5
- Started as a fork of Arcus92's [libpgs-js](https://github.com/Arcus92/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!
5
+ Started as a fork of Arcus92's [libpgs-js](https://github.com/Arcus92/libpgs-js), this project was reworked for higher performance and broader format support. It keeps the familiar high-level PGS-oriented API while adding a lower-level parser surface, VobSub support, GPU backends, and worker-backed rendering.
6
6
 
7
7
  ## Features
8
8
 
9
- - **PGS (Blu-ray)** subtitle parsing and rendering
10
- - **VobSub (DVD)** subtitle parsing and rendering
11
- - **WebGPU / WebGL2 rendering** GPU-accelerated rendering with automatic fallback: WebGPU → WebGL2 → Canvas2D
12
- - **High-performance** Rust-based rendering engine compiled to WebAssembly
13
- - **Zero-copy** data transfer between JS and WASM where possible
14
- - **Caching** for decoded bitmaps to optimize repeated rendering
15
- - **TypeScript** support with full type definitions
9
+ - PGS (Blu-ray) subtitle parsing and rendering
10
+ - VobSub (DVD) subtitle parsing and rendering
11
+ - WebGPU, WebGL2, and Canvas2D rendering with automatic fallback
12
+ - Worker-backed parsing/rendering for large subtitle files
13
+ - Rich layout controls: scale, horizontal/vertical offsets, alignment, bottom padding, safe area, opacity
14
+ - Cue metadata and parser introspection APIs
15
+ - Frame prefetching and cache control for high-level renderers
16
+ - Automatic format detection and unified loading helpers
17
+ - TypeScript support with exported event and metadata types
16
18
 
17
19
  ## Showcase
18
20
 
19
- ### PGS (Created using Spp2Pgs)
21
+ ### PGS
20
22
 
21
23
  https://gist.github.com/user-attachments/assets/55ac8e11-1964-4fb9-923e-dcac82dc7703
22
24
 
23
- ### Vobsub
25
+ ### VobSub
24
26
 
25
27
  https://gist.github.com/user-attachments/assets/a89ae9fe-23e4-4bc3-8cad-16a3f0fea665
26
28
 
27
- ### [See in action](https://a.rafasu.com/v)
29
+ ### Live demo
28
30
 
29
- ### Installation
31
+ https://a.rafasu.com/v
30
32
 
31
- **npm / bun**
33
+ ## Installation
32
34
 
33
35
  ```bash
34
36
  npm install libbitsub
@@ -36,544 +38,571 @@ npm install libbitsub
36
38
  bun add libbitsub
37
39
  ```
38
40
 
39
- **JSR (Deno)**
41
+ For JSR:
40
42
 
41
43
  ```bash
42
44
  deno add jsr:@altq/libbitsub
43
45
  ```
44
46
 
45
- ### Setup for Web Workers (Recommended)
47
+ ## Worker setup
46
48
 
47
- For best performance with large subtitle files, copy the WASM files to your public folder so Web Workers can access them:
49
+ For best performance, make the generated WASM assets reachable by the browser so the shared worker can load them:
48
50
 
49
51
  ```bash
50
- # For Next.js, Vite, or similar frameworks
51
52
  mkdir -p public/libbitsub
52
53
  cp node_modules/libbitsub/pkg/libbitsub_bg.wasm public/libbitsub/
53
54
  cp node_modules/libbitsub/pkg/libbitsub.js public/libbitsub/
54
55
  ```
55
56
 
56
- This enables off-main-thread parsing which prevents UI freezing when loading large PGS files.
57
+ `workerUrl` still exists in the option type for compatibility, but the current implementation creates an inline shared worker and resolves the WASM asset from the package loader. Supplying `workerUrl` does not change runtime behavior.
57
58
 
58
- ## Prerequisites
59
+ ## Building from source
59
60
 
60
- To build from source, you need:
61
+ Prerequisites:
61
62
 
62
- - [Rust](https://rustup.rs/) (1.70+)
63
- - [wasm-pack](https://rustwasm.github.io/wasm-pack/installer/)
63
+ - Rust
64
+ - wasm-pack
65
+ - Bun
64
66
 
65
67
  ```bash
66
- # Install wasm-pack
67
68
  cargo install wasm-pack
68
- ```
69
-
70
- ## Building
71
-
72
- ```bash
73
- # Build WASM module and TypeScript wrapper
74
69
  bun run build
75
-
76
- # Build WASM only (for development)
77
- bun run build:wasm
78
-
79
- # Build release version (optimized)
80
- bun run build:wasm:release
81
70
  ```
82
71
 
83
- ## Usage
72
+ ## Quick start
84
73
 
85
- ### Initialize WASM
74
+ Initialize the WASM module once before using any parser or renderer:
86
75
 
87
- Before using any renderer, you must initialize the WASM module:
88
-
89
- ```typescript
76
+ ```ts
90
77
  import { initWasm } from 'libbitsub'
91
78
 
92
- // Initialize WASM (do this once at app startup)
93
79
  await initWasm()
94
80
  ```
95
81
 
96
- ## High-Level API (Video Integration)
82
+ ## High-level video renderers
97
83
 
98
- The high-level API automatically handles video synchronization, canvas overlay, and subtitle fetching.
84
+ The high-level API manages subtitle loading, canvas overlay creation, playback sync, resize handling, worker usage, and renderer fallback.
99
85
 
100
- ### PGS Subtitles (Video-Integrated)
86
+ ### PGS renderer
101
87
 
102
- ```typescript
88
+ ```ts
103
89
  import { PgsRenderer } from 'libbitsub'
104
90
 
105
- // Create renderer with video element (URL-based loading)
106
91
  const renderer = new PgsRenderer({
107
92
  video: videoElement,
108
93
  subUrl: '/subtitles/movie.sup',
109
- workerUrl: '/libbitsub.js', // Optional, kept for API compatibility
110
- // Lifecycle callbacks (optional)
111
- onLoading: () => console.log('Loading subtitles...'),
112
- onLoaded: () => console.log('Subtitles loaded!'),
113
- onError: (error) => console.error('Failed to load:', error)
114
- })
115
-
116
- // Or load directly from ArrayBuffer
117
- const response = await fetch('/subtitles/movie.sup')
118
- const subtitleData = await response.arrayBuffer()
119
-
120
- const renderer = new PgsRenderer({
121
- video: videoElement,
122
- subContent: subtitleData, // Load directly from ArrayBuffer
123
- onLoading: () => console.log('Loading subtitles...'),
124
- onLoaded: () => console.log('Subtitles loaded!'),
125
- onError: (error) => console.error('Failed to load:', error)
94
+ displaySettings: {
95
+ scale: 1.1,
96
+ bottomPadding: 4,
97
+ safeArea: 5
98
+ },
99
+ cacheLimit: 32,
100
+ prefetchWindow: { before: 1, after: 2 },
101
+ onEvent: (event) => {
102
+ if (event.type === 'worker-state') {
103
+ console.log('worker', event.ready ? 'ready' : 'starting', event.sessionId)
104
+ }
105
+ }
126
106
  })
127
107
 
128
- // The renderer automatically:
129
- // - Fetches the subtitle file (if using subUrl) or uses provided ArrayBuffer
130
- // - Creates a canvas overlay on the video
131
- // - Syncs rendering with video playback
132
- // - Handles resize events
133
-
134
- // When done:
135
108
  renderer.dispose()
136
109
  ```
137
110
 
138
- ### VobSub Subtitles (Video-Integrated)
111
+ ### VobSub renderer
139
112
 
140
- ```typescript
113
+ ```ts
141
114
  import { VobSubRenderer } from 'libbitsub'
142
115
 
143
- // Create renderer with video element (URL-based loading)
144
116
  const renderer = new VobSubRenderer({
145
117
  video: videoElement,
146
118
  subUrl: '/subtitles/movie.sub',
147
- idxUrl: '/subtitles/movie.idx', // Optional, defaults to .sub path with .idx extension
148
- workerUrl: '/libbitsub.js', // Optional
149
- // Lifecycle callbacks (optional)
150
- onLoading: () => setIsLoading(true),
151
- onLoaded: () => setIsLoading(false),
152
- onError: (error) => {
153
- setIsLoading(false)
154
- console.error('Subtitle error:', error)
155
- }
119
+ idxUrl: '/subtitles/movie.idx'
156
120
  })
157
121
 
158
- // Or load directly from ArrayBuffer
159
- const [subResponse, idxResponse] = await Promise.all([fetch('/subtitles/movie.sub'), fetch('/subtitles/movie.idx')])
160
- const subData = await subResponse.arrayBuffer()
161
- const idxData = await idxResponse.text()
122
+ renderer.setDebandThreshold(64)
123
+ renderer.setDebandRange(15)
124
+ ```
162
125
 
163
- const renderer = new VobSubRenderer({
126
+ ### Automatic format detection
127
+
128
+ ```ts
129
+ import { createAutoSubtitleRenderer } from 'libbitsub'
130
+
131
+ const renderer = createAutoSubtitleRenderer({
164
132
  video: videoElement,
165
- subContent: subData, // Load .sub directly from ArrayBuffer
166
- idxContent: idxData, // Load .idx directly from string
167
- onLoading: () => setIsLoading(true),
168
- onLoaded: () => setIsLoading(false),
169
- onError: (error) => {
170
- setIsLoading(false)
171
- console.error('Subtitle error:', error)
172
- }
133
+ subUrl: '/subtitles/track.sup',
134
+ fileName: 'track.sup'
173
135
  })
174
-
175
- // When done:
176
- renderer.dispose()
177
136
  ```
178
137
 
179
- ### Subtitle Display Settings
138
+ Automatic detection uses file hints when available and otherwise inspects the binary payload. If the format cannot be identified confidently, it throws instead of silently forcing a parser.
180
139
 
181
- Both `PgsRenderer` and `VobSubRenderer` support real-time customization of subtitle size and position:
140
+ ## Layout controls
182
141
 
183
- ```typescript
184
- // Get current settings
185
- const settings = renderer.getDisplaySettings()
186
- console.log(settings)
187
- // Output: { scale: 1.0, verticalOffset: 0 }
142
+ Both `PgsRenderer` and `VobSubRenderer` support runtime layout changes:
188
143
 
189
- // Update settings
144
+ ```ts
190
145
  renderer.setDisplaySettings({
191
- scale: 1.2, // 1.2 = 120% size
192
- verticalOffset: -10 // -10% (move up 10% of video height)
146
+ scale: 1.2,
147
+ verticalOffset: -8,
148
+ horizontalOffset: 2,
149
+ horizontalAlign: 'center',
150
+ bottomPadding: 6,
151
+ safeArea: 5,
152
+ opacity: 0.92
193
153
  })
194
154
 
195
- // Reset to defaults
155
+ const settings = renderer.getDisplaySettings()
196
156
  renderer.resetDisplaySettings()
197
157
  ```
198
158
 
199
- ### Debanding (VobSub)
200
-
201
- 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:
202
-
203
- ```typescript
204
- import { VobSubRenderer } from 'libbitsub'
159
+ `SubtitleDisplaySettings`:
205
160
 
206
- const renderer = new VobSubRenderer({
207
- video: videoElement,
208
- subUrl: '/subtitles/movie.sub'
209
- })
161
+ | Field | Type | Range / values | Meaning |
162
+ | --- | --- | --- | --- |
163
+ | `scale` | number | `0.1` to `3.0` | Overall subtitle scale |
164
+ | `verticalOffset` | number | `-50` to `50` | Vertical movement as percent of video height |
165
+ | `horizontalOffset` | number | `-50` to `50` | Horizontal movement as percent of video width |
166
+ | `horizontalAlign` | `'left' \| 'center' \| 'right'` | fixed set | Anchor used when scaling subtitle groups |
167
+ | `bottomPadding` | number | `0` to `50` | Extra padding from the bottom edge |
168
+ | `safeArea` | number | `0` to `25` | Clamp subtitles inside a video-safe area |
169
+ | `opacity` | number | `0.0` to `1.0` | Global subtitle opacity |
210
170
 
211
- // Debanding is enabled by default; call to disable if needed
212
- // renderer.setDebandEnabled(false)
171
+ ## Metadata and introspection
213
172
 
214
- // Fine-tune debanding parameters
215
- renderer.setDebandThreshold(64.0) // Higher = more aggressive smoothing
216
- renderer.setDebandRange(15) // Pixel radius for sampling
173
+ High-level renderers expose parser and cue metadata:
217
174
 
218
- // Check if debanding is active
219
- console.log(renderer.debandEnabled) // true
175
+ ```ts
176
+ const metadata = renderer.getMetadata()
177
+ const currentCue = renderer.getCurrentCueMetadata()
178
+ const cue42 = renderer.getCueMetadata(42)
220
179
  ```
221
180
 
222
- **Low-Level API:**
223
-
224
- ```typescript
225
- import { VobSubParserLowLevel } from 'libbitsub'
181
+ Low-level parsers expose the same model:
226
182
 
227
- const parser = new VobSubParserLowLevel()
228
- parser.loadFromData(idxContent, subData)
183
+ ```ts
184
+ import { PgsParser, UnifiedSubtitleParser, VobSubParserLowLevel } from 'libbitsub'
229
185
 
230
- // Configure debanding before rendering
231
- parser.setDebandEnabled(true)
232
- parser.setDebandThreshold(48.0)
233
- parser.setDebandRange(12)
186
+ const parser = new UnifiedSubtitleParser()
187
+ const detected = parser.loadAuto({ data: subtitleBytes, fileName: 'track.sup' })
234
188
 
235
- // Rendered frames will have debanding applied
236
- const frame = parser.renderAtIndex(0)
189
+ console.log(detected)
190
+ console.log(parser.getMetadata())
191
+ console.log(parser.getCueMetadata(0))
237
192
  ```
238
193
 
239
- **Debanding Settings:**
240
-
241
- | Property | Type | Default | Range | Description |
242
- | ----------- | ------- | ------- | --------- | ------------------------------------------------ |
243
- | `enabled` | boolean | `true` | - | Enable/disable the debanding filter |
244
- | `threshold` | number | `64.0` | 0.0-255.0 | Difference threshold; higher = more smoothing |
245
- | `range` | number | `15` | 1-64 | Sample radius in pixels; higher = wider sampling |
194
+ Metadata includes:
246
195
 
247
- **Notes:**
196
+ - Track format, cue count, and presentation size
197
+ - Cue start/end time and duration
198
+ - Rendered cue bounds when available
199
+ - PGS composition count, palette ID, composition state
200
+ - VobSub language, track ID, IDX metadata presence, file position where available
248
201
 
249
- - Debanding is applied post-decode on the RGBA output
250
- - Uses cross-shaped sampling with factor-based blending (neo_f3kdb sample_mode 6 style)
251
- - Transparent pixels are skipped for performance
252
- - Deterministic output (same input = same output)
202
+ ## Cache control and prefetching
253
203
 
254
- **Settings Reference:**
204
+ High-level renderers expose cache helpers:
255
205
 
256
- - `scale` (number): Scale factor for subtitles.
257
- - `1.0` = 100% (Original size)
258
- - `0.5` = 50%
259
- - `2.0` = 200%
260
- - Range: `0.1` to `3.0`
206
+ ```ts
207
+ renderer.setCacheLimit(48)
208
+ await renderer.prefetchRange(10, 20)
209
+ await renderer.prefetchAroundTime(videoElement.currentTime)
210
+ renderer.clearFrameCache()
211
+ ```
261
212
 
262
- - `verticalOffset` (number): Vertical position offset as a percentage of video height.
263
- - `0` = Original position
264
- - Negative values move up (e.g., `-10` moves up by 10% of height)
265
- - Positive values move down (e.g., `10` moves down by 10% of height)
266
- - Range: `-50` to `50`
213
+ `clearFrameCache()` clears both the renderer-side frame map and the underlying parser cache for the active session.
267
214
 
268
- ### Performance Statistics
215
+ ## Observability events
269
216
 
270
- Both `PgsRenderer` and `VobSubRenderer` provide real-time performance metrics:
217
+ Use `onEvent` to observe renderer lifecycle and runtime behavior:
271
218
 
272
- ```typescript
273
- // Get performance statistics
274
- const stats = renderer.getStats()
275
- console.log(stats)
276
- // Output:
277
- // {
278
- // framesRendered: 120,
279
- // framesDropped: 2,
280
- // avgRenderTime: 1.45,
281
- // maxRenderTime: 8.32,
282
- // minRenderTime: 0.12,
283
- // lastRenderTime: 1.23,
284
- // renderFps: 60,
285
- // usingWorker: true,
286
- // cachedFrames: 5,
287
- // pendingRenders: 0,
288
- // totalEntries: 847,
289
- // currentIndex: 42
290
- // }
291
-
292
- // Example: Display stats in a debug overlay
293
- setInterval(() => {
294
- const stats = renderer.getStats()
295
- debugOverlay.textContent = `
296
- FPS: ${stats.renderFps}
297
- Frames: ${stats.framesRendered} (dropped: ${stats.framesDropped})
298
- Avg render: ${stats.avgRenderTime}ms
299
- Worker: ${stats.usingWorker ? 'Yes' : 'No'}
300
- Cache: ${stats.cachedFrames} frames
301
- `
302
- }, 1000)
219
+ ```ts
220
+ const renderer = new PgsRenderer({
221
+ video: videoElement,
222
+ subUrl: '/subtitles/movie.sup',
223
+ onEvent: (event) => {
224
+ switch (event.type) {
225
+ case 'loading':
226
+ case 'loaded':
227
+ case 'error':
228
+ case 'renderer-change':
229
+ case 'worker-state':
230
+ case 'cache-change':
231
+ case 'cue-change':
232
+ case 'stats':
233
+ console.log(event)
234
+ break
235
+ }
236
+ }
237
+ })
303
238
  ```
304
239
 
305
- **Stats Reference:**
306
-
307
- | Property | Type | Description |
308
- | ---------------- | ------- | -------------------------------------------------------------- |
309
- | `framesRendered` | number | Total frames rendered since initialization |
310
- | `framesDropped` | number | Frames dropped due to slow rendering (>16.67ms) |
311
- | `avgRenderTime` | number | Average render time in milliseconds (rolling 60-sample window) |
312
- | `maxRenderTime` | number | Maximum render time in milliseconds |
313
- | `minRenderTime` | number | Minimum render time in milliseconds |
314
- | `lastRenderTime` | number | Most recent render time in milliseconds |
315
- | `renderFps` | number | Current renders per second (based on last 1 second) |
316
- | `usingWorker` | boolean | Whether rendering is using Web Worker (off-main-thread) |
317
- | `cachedFrames` | number | Number of decoded frames currently cached |
318
- | `pendingRenders` | number | Number of frames currently being decoded asynchronously |
319
- | `totalEntries` | number | Total subtitle entries/display sets in the loaded file |
320
- | `currentIndex` | number | Index of the currently displayed subtitle |
321
-
322
- ### GPU-Accelerated Rendering
323
-
324
- libbitsub automatically selects the best available GPU renderer at startup, following this fallback chain:
325
-
326
- **WebGPU → WebGL2 → Canvas2D**
327
-
328
- ```typescript
329
- import { PgsRenderer, isWebGPUSupported, isWebGL2Supported } from 'libbitsub'
330
-
331
- // Check renderer support
332
- if (isWebGPUSupported()) {
333
- console.log('WebGPU available')
334
- } else if (isWebGL2Supported()) {
335
- console.log('WebGL2 available')
336
- } else {
337
- console.log('Falling back to Canvas2D')
338
- }
240
+ ### Example: event-driven prefetch and cue inspection
241
+
242
+ ```ts
243
+ import { PgsRenderer } from 'libbitsub'
339
244
 
340
245
  const renderer = new PgsRenderer({
341
246
  video: videoElement,
342
247
  subUrl: '/subtitles/movie.sup',
343
- onWebGPUFallback: () => console.log('WebGPU unavailable, trying WebGL2'),
344
- onWebGL2Fallback: () => console.log('WebGL2 unavailable, using Canvas2D')
248
+ prefetchWindow: { before: 1, after: 2 },
249
+ onEvent: async (event) => {
250
+ switch (event.type) {
251
+ case 'loaded': {
252
+ console.log('track metadata', event.metadata)
253
+ await renderer.prefetchAroundTime(videoElement.currentTime)
254
+ break
255
+ }
256
+
257
+ case 'cue-change': {
258
+ if (!event.cue) {
259
+ console.log('no active subtitle cue')
260
+ break
261
+ }
262
+
263
+ const cue = renderer.getCueMetadata(event.cue.index)
264
+ console.log('active cue', {
265
+ index: cue?.index,
266
+ startTime: cue?.startTime,
267
+ endTime: cue?.endTime,
268
+ bounds: cue?.bounds,
269
+ compositionCount: cue?.compositionCount
270
+ })
271
+ break
272
+ }
273
+
274
+ case 'cache-change': {
275
+ console.log('cache', `${event.cachedFrames}/${event.cacheLimit}`, 'pending', event.pendingRenders)
276
+ break
277
+ }
278
+ }
279
+ }
345
280
  })
346
- ```
347
281
 
348
- **Options:**
349
-
350
- - `onWebGPUFallback` (function): Callback when WebGPU initialisation fails
351
- - `onWebGL2Fallback` (function): Callback when WebGL2 initialisation fails
282
+ videoElement.addEventListener('seeked', () => {
283
+ renderer.prefetchAroundTime(videoElement.currentTime).catch(console.error)
284
+ })
352
285
 
353
- **Renderer capabilities:**
286
+ // later
287
+ renderer.dispose()
288
+ ```
354
289
 
355
- | Renderer | Premultiplied alpha | Linear sampling | Browser support |
356
- | -------- | ------------------- | --------------- | --------------- |
357
- | WebGPU | ✅ | ✅ | Chrome 113+, Firefox 141+, Edge 113+ |
358
- | WebGL2 | ✅ | ✅ | All modern browsers |
359
- | Canvas2D | — | ✅ | Universal |
290
+ Emitted events:
360
291
 
361
- ## Low-Level API (Programmatic Use)
292
+ | Event | Payload |
293
+ | --- | --- |
294
+ | `loading` | subtitle format |
295
+ | `loaded` | subtitle format and parser metadata |
296
+ | `error` | subtitle format and `Error` |
297
+ | `renderer-change` | active backend: `webgpu`, `webgl2`, or `canvas2d` |
298
+ | `worker-state` | whether worker mode is enabled, ready, fallback status, and the active session ID |
299
+ | `cache-change` | cached frame count, pending renders, and configured cache limit |
300
+ | `cue-change` | current cue metadata or `null` when nothing is displayed |
301
+ | `stats` | periodic renderer stats snapshot |
362
302
 
363
- For more control over rendering, use the low-level parsers directly.
303
+ ## Performance stats
364
304
 
365
- ### PGS Subtitles (Low-Level)
305
+ ```ts
306
+ const stats = renderer.getStats()
307
+ ```
366
308
 
367
- ```typescript
368
- import { initWasm, PgsParser } from 'libbitsub'
309
+ `SubtitleRendererStats` includes:
369
310
 
370
- await initWasm()
311
+ - `framesRendered`
312
+ - `framesDropped`
313
+ - `avgRenderTime`
314
+ - `maxRenderTime`
315
+ - `minRenderTime`
316
+ - `lastRenderTime`
317
+ - `renderFps`
318
+ - `usingWorker`
319
+ - `cachedFrames`
320
+ - `pendingRenders`
321
+ - `totalEntries`
322
+ - `currentIndex`
371
323
 
372
- const parser = new PgsParser()
324
+ ## Low-level APIs
373
325
 
374
- // Load PGS data from a .sup file
375
- const response = await fetch('subtitles.sup')
376
- const data = new Uint8Array(await response.arrayBuffer())
377
- parser.load(data)
326
+ ### PGS parser
378
327
 
379
- // Get timestamps
380
- const timestamps = parser.getTimestamps() // Float64Array in milliseconds
328
+ ```ts
329
+ import { PgsParser } from 'libbitsub'
381
330
 
382
- // Render at a specific time
383
- const subtitleData = parser.renderAtTimestamp(currentTimeInSeconds)
384
- if (subtitleData) {
385
- for (const comp of subtitleData.compositionData) {
386
- ctx.putImageData(comp.pixelData, comp.x, comp.y)
387
- }
388
- }
331
+ const parser = new PgsParser()
332
+ parser.load(new Uint8Array(arrayBuffer))
389
333
 
390
- // Clean up
391
- parser.dispose()
334
+ const timestamps = parser.getTimestamps()
335
+ const frame = parser.renderAtIndex(0)
336
+ const metadata = parser.getMetadata()
392
337
  ```
393
338
 
394
- ### VobSub Subtitles (Low-Level)
339
+ ### VobSub parser
395
340
 
396
- ```typescript
397
- import { initWasm, VobSubParserLowLevel } from 'libbitsub'
398
-
399
- await initWasm()
341
+ ```ts
342
+ import { VobSubParserLowLevel } from 'libbitsub'
400
343
 
401
344
  const parser = new VobSubParserLowLevel()
345
+ parser.loadFromData(idxContent, new Uint8Array(subArrayBuffer))
346
+ parser.setDebandEnabled(true)
402
347
 
403
- // Load from IDX + SUB files
404
- const idxResponse = await fetch('subtitles.idx')
405
- const idxContent = await idxResponse.text()
406
- const subResponse = await fetch('subtitles.sub')
407
- const subData = new Uint8Array(await subResponse.arrayBuffer())
348
+ const frame = parser.renderAtTimestamp(120.5)
349
+ const cue = parser.getCueMetadata(0)
350
+ ```
408
351
 
409
- parser.loadFromData(idxContent, subData)
352
+ ### Unified parser
410
353
 
411
- // Or load from SUB file only
412
- // parser.loadFromSubOnly(subData);
354
+ ```ts
355
+ import { UnifiedSubtitleParser, detectSubtitleFormat } from 'libbitsub'
413
356
 
414
- // Render
415
- const subtitleData = parser.renderAtTimestamp(currentTimeInSeconds)
416
- if (subtitleData) {
417
- for (const comp of subtitleData.compositionData) {
418
- ctx.putImageData(comp.pixelData, comp.x, comp.y)
419
- }
420
- }
357
+ const format = detectSubtitleFormat({ data: subtitleBytes, fileName: 'track.sup' })
421
358
 
422
- parser.dispose()
359
+ const parser = new UnifiedSubtitleParser()
360
+ parser.loadAuto({ data: subtitleBytes, fileName: 'track.sup' })
423
361
  ```
424
362
 
425
- ### Unified Parser
363
+ ## GPU backends
426
364
 
427
- For handling both formats with a single API:
365
+ libbitsub prefers:
428
366
 
429
- ```typescript
430
- import { initWasm, UnifiedSubtitleParser } from 'libbitsub'
367
+ 1. WebGPU
368
+ 2. WebGL2
369
+ 3. Canvas2D
431
370
 
432
- await initWasm()
371
+ ```ts
372
+ import { isWebGL2Supported, isWebGPUSupported } from 'libbitsub'
433
373
 
434
- const parser = new UnifiedSubtitleParser()
374
+ console.log({
375
+ webgpu: isWebGPUSupported(),
376
+ webgl2: isWebGL2Supported()
377
+ })
378
+ ```
435
379
 
436
- // Load PGS
437
- parser.loadPgs(pgsData)
380
+ ## Notes
438
381
 
439
- // Or load VobSub
440
- // parser.loadVobSub(idxContent, subData);
382
+ - Worker mode is shared, but subtitle parser state is isolated per renderer session.
383
+ - Multiple subtitle renderers can coexist without reusing the same parser instance.
384
+ - If worker startup fails, the high-level API falls back to main-thread parsing.
385
+ - The library only handles bitmap subtitle formats. It does not parse text subtitle formats such as SRT or ASS.
441
386
 
442
- console.log(parser.format) // 'pgs' or 'vobsub'
387
+ ## API Reference
443
388
 
444
- const subtitleData = parser.renderAtTimestamp(time)
445
- // ... render to canvas
389
+ ### Top-level exports
446
390
 
447
- parser.dispose()
448
- ```
391
+ - `initWasm(): Promise<void>` initializes the WASM module.
392
+ - `isWasmInitialized(): boolean` reports whether initialization has completed.
393
+ - `isWebGPUSupported(): boolean` checks WebGPU support.
394
+ - `detectSubtitleFormat(source: AutoSubtitleSource): 'pgs' | 'vobsub' | null` detects the bitmap subtitle format from file hints or binary data.
395
+ - `createAutoSubtitleRenderer(options: AutoVideoSubtitleOptions): PgsRenderer | VobSubRenderer` creates a high-level renderer after format detection.
396
+ - Legacy aliases remain exported: `PGSRenderer`, `VobsubRenderer`, `UnifiedSubtitleRenderer`.
449
397
 
450
- ## API Reference
451
-
452
- ### High-Level (Video-Integrated)
398
+ ### High-level renderers
453
399
 
454
400
  #### `PgsRenderer`
455
401
 
456
- - `constructor(options: VideoSubtitleOptions)` - Create video-integrated PGS renderer
457
- - `getDisplaySettings(): SubtitleDisplaySettings` - Get current display settings
458
- - `setDisplaySettings(settings: Partial<SubtitleDisplaySettings>): void` - Update display settings
459
- - `resetDisplaySettings(): void` - Reset display settings to defaults
460
- - `getStats(): SubtitleRendererStats` - Get performance statistics
461
- - `dispose(): void` - Clean up all resources
402
+ - `constructor(options: VideoSubtitleOptions)` creates a video-synced PGS renderer.
403
+ - `getDisplaySettings(): SubtitleDisplaySettings` returns the current layout settings.
404
+ - `setDisplaySettings(settings: Partial<SubtitleDisplaySettings>): void` updates layout settings.
405
+ - `resetDisplaySettings(): void` resets layout settings to defaults.
406
+ - `getStats(): SubtitleRendererStats` returns render statistics.
407
+ - `getMetadata(): SubtitleParserMetadata | null` returns track-level metadata.
408
+ - `getCurrentCueMetadata(): SubtitleCueMetadata | null` returns the currently displayed cue metadata.
409
+ - `getCueMetadata(index: number): SubtitleCueMetadata | null` returns metadata for a specific cue.
410
+ - `getCacheLimit(): number` returns the active frame-cache limit.
411
+ - `setCacheLimit(limit: number): void` updates the frame-cache limit.
412
+ - `clearFrameCache(): void` clears the renderer-side and parser-side frame cache.
413
+ - `prefetchRange(startIndex: number, endIndex: number): Promise<void>` prefetches decoded frames for a cue range.
414
+ - `prefetchAroundTime(time: number, before?: number, after?: number): Promise<void>` prefetches around a playback time in seconds.
415
+ - `dispose(): void` releases DOM, parser, and worker resources.
462
416
 
463
417
  #### `VobSubRenderer`
464
418
 
465
- - `constructor(options: VideoVobSubOptions)` - Create video-integrated VobSub renderer
466
- - `getDisplaySettings(): SubtitleDisplaySettings` - Get current display settings
467
- - `setDisplaySettings(settings: Partial<SubtitleDisplaySettings>): void` - Update display settings
468
- - `resetDisplaySettings(): void` - Reset display settings to defaults
469
- - `getStats(): SubtitleRendererStats` - Get performance statistics
470
- - `setDebandEnabled(enabled: boolean): void` - Enable/disable debanding filter
471
- - `setDebandThreshold(threshold: number): void` - Set debanding threshold (0.0-255.0)
472
- - `setDebandRange(range: number): void` - Set debanding sample range (1-64)
473
- - `debandEnabled: boolean` - Check if debanding is enabled
474
- - `dispose(): void` - Clean up all resources
419
+ - Supports all `PgsRenderer` methods above.
420
+ - `setDebandEnabled(enabled: boolean): void` enables or disables debanding.
421
+ - `setDebandThreshold(threshold: number): void` updates the deband threshold.
422
+ - `setDebandRange(range: number): void` updates the deband sample range.
423
+ - `debandEnabled: boolean` reports whether debanding is enabled.
475
424
 
476
- ### Low-Level (Programmatic)
425
+ ### Low-level parsers
477
426
 
478
427
  #### `PgsParser`
479
428
 
480
- - `load(data: Uint8Array): number` - Load PGS data, returns display set count
481
- - `getTimestamps(): Float64Array` - Get all timestamps in milliseconds
482
- - `count: number` - Number of display sets
483
- - `findIndexAtTimestamp(timeSeconds: number): number` - Find index for timestamp
484
- - `renderAtIndex(index: number): SubtitleData | undefined` - Render at index
485
- - `renderAtTimestamp(timeSeconds: number): SubtitleData | undefined` - Render at time
486
- - `clearCache(): void` - Clear decoded bitmap cache
487
- - `dispose(): void` - Release resources
429
+ - `load(data: Uint8Array): number` loads PGS data and returns the cue count.
430
+ - `getTimestamps(): Float64Array` returns cue timestamps in milliseconds.
431
+ - `count: number` returns the number of cues.
432
+ - `findIndexAtTimestamp(timeSeconds: number): number` finds the cue index for a playback time in seconds.
433
+ - `renderAtIndex(index: number): SubtitleData | undefined` renders a cue by index.
434
+ - `renderAtTimestamp(timeSeconds: number): SubtitleData | undefined` renders a cue at a playback time.
435
+ - `getMetadata(): SubtitleParserMetadata` returns parser metadata.
436
+ - `getCueMetadata(index: number): SubtitleCueMetadata | null` returns cue metadata.
437
+ - `clearCache(): void` clears parser-side caches.
438
+ - `dispose(): void` frees parser resources.
488
439
 
489
440
  #### `VobSubParserLowLevel`
490
441
 
491
- - `loadFromData(idxContent: string, subData: Uint8Array): void` - Load IDX + SUB
492
- - `loadFromSubOnly(subData: Uint8Array): void` - Load SUB only
493
- - `setDebandEnabled(enabled: boolean): void` - Enable/disable debanding filter
494
- - `setDebandThreshold(threshold: number): void` - Set debanding threshold (0.0-255.0)
495
- - `setDebandRange(range: number): void` - Set debanding sample range (1-64)
496
- - `debandEnabled: boolean` - Check if debanding is enabled
497
- - Same rendering methods as PgsParser
442
+ - `loadFromData(idxContent: string, subData: Uint8Array): void` loads IDX and SUB data.
443
+ - `loadFromSubOnly(subData: Uint8Array): void` loads SUB-only VobSub data.
444
+ - `getTimestamps(): Float64Array`, `count`, `findIndexAtTimestamp()`, `renderAtIndex()`, `renderAtTimestamp()`, `getMetadata()`, `getCueMetadata()`, `clearCache()`, and `dispose()` behave like `PgsParser`.
445
+ - `setDebandEnabled(enabled: boolean): void`, `setDebandThreshold(threshold: number): void`, `setDebandRange(range: number): void`, and `debandEnabled` control debanding.
498
446
 
499
447
  #### `UnifiedSubtitleParser`
500
448
 
501
- - `loadPgs(data: Uint8Array): number` - Load PGS data
502
- - `loadVobSub(idxContent: string, subData: Uint8Array): void` - Load VobSub
503
- - `loadVobSubOnly(subData: Uint8Array): void` - Load SUB only
504
- - `format: 'pgs' | 'vobsub' | null` - Current format
505
- - Same rendering methods as above
449
+ - `loadPgs(data: Uint8Array): number` loads PGS data.
450
+ - `loadVobSub(idxContent: string, subData: Uint8Array): void` loads VobSub from IDX and SUB.
451
+ - `loadVobSubOnly(subData: Uint8Array): void` loads SUB-only VobSub data.
452
+ - `loadAuto(source: AutoSubtitleSource): SubtitleFormatName` detects and loads a supported bitmap subtitle format.
453
+ - `format: 'pgs' | 'vobsub' | null` returns the active format.
454
+ - `getTimestamps()`, `count`, `findIndexAtTimestamp()`, `renderAtIndex()`, `renderAtTimestamp()`, `getMetadata()`, `getCueMetadata()`, `clearCache()`, and `dispose()` are available as on the format-specific parsers.
506
455
 
507
- ### Types
456
+ ### Core option and data types
508
457
 
509
458
  #### `VideoSubtitleOptions`
510
459
 
511
- ```typescript
460
+ ```ts
512
461
  interface VideoSubtitleOptions {
513
- video: HTMLVideoElement // Video element to sync with
514
- subUrl?: string // URL to subtitle file (provide this OR subContent)
515
- subContent?: ArrayBuffer // Direct subtitle content (provide this OR subUrl)
516
- workerUrl?: string // Worker URL (for API compatibility)
517
- onLoading?: () => void // Called when subtitle loading starts
518
- onLoaded?: () => void // Called when subtitle loading completes
519
- onError?: (error: Error) => void // Called when subtitle loading fails
520
- onWebGPUFallback?: () => void // Called when WebGPU init fails
521
- onWebGL2Fallback?: () => void // Called when WebGL2 init fails
462
+ video: HTMLVideoElement
463
+ subUrl?: string
464
+ subContent?: ArrayBuffer
465
+ workerUrl?: string
466
+ onLoading?: () => void
467
+ onLoaded?: () => void
468
+ onError?: (error: Error) => void
469
+ onWebGPUFallback?: () => void
470
+ onWebGL2Fallback?: () => void
471
+ displaySettings?: Partial<SubtitleDisplaySettings>
472
+ cacheLimit?: number
473
+ prefetchWindow?: {
474
+ before?: number
475
+ after?: number
476
+ }
477
+ onEvent?: (event: SubtitleRendererEvent) => void
522
478
  }
523
479
  ```
524
480
 
525
481
  #### `VideoVobSubOptions`
526
482
 
527
- ```typescript
483
+ ```ts
528
484
  interface VideoVobSubOptions extends VideoSubtitleOptions {
529
- idxUrl?: string // URL to .idx file (optional, defaults to subUrl with .idx extension)
530
- idxContent?: string // Direct .idx content (provide this OR idxUrl)
485
+ idxUrl?: string
486
+ idxContent?: string
487
+ }
488
+ ```
489
+
490
+ #### `AutoVideoSubtitleOptions`
491
+
492
+ ```ts
493
+ interface AutoVideoSubtitleOptions extends Omit<VideoVobSubOptions, 'subUrl' | 'idxUrl'> {
494
+ subUrl?: string
495
+ idxUrl?: string
496
+ fileName?: string
531
497
  }
532
498
  ```
533
499
 
534
500
  #### `SubtitleDisplaySettings`
535
501
 
536
- ```typescript
502
+ ```ts
537
503
  interface SubtitleDisplaySettings {
538
- // Scale factor (1.0 = 100%, 0.5 = 50%, 2.0 = 200%)
539
504
  scale: number
540
- // Vertical offset as % of video height (-50 to 50)
541
505
  verticalOffset: number
506
+ horizontalOffset: number
507
+ horizontalAlign: 'left' | 'center' | 'right'
508
+ bottomPadding: number
509
+ safeArea: number
510
+ opacity: number
542
511
  }
543
512
  ```
544
513
 
545
- #### `SubtitleRendererStats`
546
-
547
- ```typescript
548
- interface SubtitleRendererStats {
549
- framesRendered: number // Total frames rendered since initialization
550
- framesDropped: number // Frames dropped due to slow rendering
551
- avgRenderTime: number // Average render time in milliseconds
552
- maxRenderTime: number // Maximum render time in milliseconds
553
- minRenderTime: number // Minimum render time in milliseconds
554
- lastRenderTime: number // Last render time in milliseconds
555
- renderFps: number // Current FPS (renders per second)
556
- usingWorker: boolean // Whether rendering is using web worker
557
- cachedFrames: number // Number of cached frames
558
- pendingRenders: number // Number of pending renders
559
- totalEntries: number // Total subtitle entries/display sets
560
- currentIndex: number // Current subtitle index being displayed
514
+ #### `SubtitleRendererEvent`
515
+
516
+ ```ts
517
+ type SubtitleRendererEvent =
518
+ | { type: 'loading'; format: SubtitleFormatName }
519
+ | { type: 'loaded'; format: SubtitleFormatName; metadata: SubtitleParserMetadata }
520
+ | { type: 'error'; format: SubtitleFormatName; error: Error }
521
+ | { type: 'renderer-change'; renderer: 'webgpu' | 'webgl2' | 'canvas2d' }
522
+ | { type: 'worker-state'; enabled: boolean; ready: boolean; sessionId: string | null; fallback?: boolean }
523
+ | { type: 'cache-change'; cachedFrames: number; pendingRenders: number; cacheLimit: number }
524
+ | { type: 'cue-change'; cue: SubtitleCueMetadata | null }
525
+ | { type: 'stats'; stats: SubtitleRendererStatsSnapshot }
526
+ ```
527
+
528
+ #### `SubtitleRendererStats` and `SubtitleRendererStatsSnapshot`
529
+
530
+ Both shapes expose:
531
+
532
+ - `framesRendered`
533
+ - `framesDropped`
534
+ - `avgRenderTime`
535
+ - `maxRenderTime`
536
+ - `minRenderTime`
537
+ - `lastRenderTime`
538
+ - `renderFps`
539
+ - `usingWorker`
540
+ - `cachedFrames`
541
+ - `pendingRenders`
542
+ - `totalEntries`
543
+ - `currentIndex`
544
+
545
+ #### `SubtitleParserMetadata`
546
+
547
+ ```ts
548
+ interface SubtitleParserMetadata {
549
+ format: 'pgs' | 'vobsub'
550
+ cueCount: number
551
+ screenWidth: number
552
+ screenHeight: number
553
+ language?: string | null
554
+ trackId?: string | null
555
+ hasIdxMetadata?: boolean
556
+ }
557
+ ```
558
+
559
+ #### `SubtitleCueMetadata`
560
+
561
+ ```ts
562
+ interface SubtitleCueMetadata {
563
+ index: number
564
+ format: 'pgs' | 'vobsub'
565
+ startTime: number
566
+ endTime: number
567
+ duration: number
568
+ screenWidth: number
569
+ screenHeight: number
570
+ bounds: SubtitleCueBounds | null
571
+ compositionCount: number
572
+ paletteId?: number
573
+ compositionState?: number
574
+ language?: string | null
575
+ trackId?: string | null
576
+ filePosition?: number
577
+ }
578
+ ```
579
+
580
+ #### `AutoSubtitleSource`
581
+
582
+ ```ts
583
+ interface AutoSubtitleSource {
584
+ data?: ArrayBuffer | Uint8Array
585
+ subData?: ArrayBuffer | Uint8Array
586
+ idxContent?: string
587
+ fileName?: string
588
+ subUrl?: string
589
+ idxUrl?: string
561
590
  }
562
591
  ```
563
592
 
564
593
  #### `SubtitleData`
565
594
 
566
- ```typescript
595
+ ```ts
567
596
  interface SubtitleData {
568
- width: number // Screen width
569
- height: number // Screen height
597
+ width: number
598
+ height: number
570
599
  compositionData: SubtitleCompositionData[]
571
600
  }
572
601
 
573
602
  interface SubtitleCompositionData {
574
- pixelData: ImageData // RGBA pixel data
575
- x: number // X position
576
- y: number // Y position
603
+ pixelData: ImageData
604
+ x: number
605
+ y: number
577
606
  }
578
607
  ```
579
608