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
@@ -4,14 +4,19 @@
4
4
  */
5
5
  import { initWasm } from './wasm';
6
6
  import { getOrCreateWorker, sendToWorker } from './worker';
7
- import { binarySearchTimestamp, convertFrameData, createWorkerState } from './utils';
7
+ import { binarySearchTimestamp, convertFrameData, createWorkerSessionId, createWorkerState, detectSubtitleFormat, getSubtitleBounds, setCacheLimit as applyCacheLimit, setCachedFrame } from './utils';
8
8
  import { PgsParser, VobSubParserLowLevel } from './parsers';
9
9
  import { WebGPURenderer, isWebGPUSupported } from './webgpu-renderer';
10
10
  import { WebGL2Renderer, isWebGL2Supported } from './webgl2-renderer';
11
11
  /** Default display settings */
12
12
  const DEFAULT_DISPLAY_SETTINGS = {
13
13
  scale: 1.0,
14
- verticalOffset: 0
14
+ verticalOffset: 0,
15
+ horizontalOffset: 0,
16
+ horizontalAlign: 'center',
17
+ bottomPadding: 0,
18
+ safeArea: 0,
19
+ opacity: 1.0
15
20
  };
16
21
  /**
17
22
  * Base class for video-integrated subtitle renderers.
@@ -19,6 +24,7 @@ const DEFAULT_DISPLAY_SETTINGS = {
19
24
  */
20
25
  class BaseVideoSubtitleRenderer {
21
26
  video;
27
+ format;
22
28
  subUrl;
23
29
  subContent;
24
30
  canvas = null;
@@ -32,8 +38,18 @@ class BaseVideoSubtitleRenderer {
32
38
  tempCanvas = null;
33
39
  tempCtx = null;
34
40
  lastRenderedData = null;
41
+ lastCueIndex = null;
42
+ currentCueMetadata = null;
43
+ parserMetadata = null;
35
44
  /** Display settings for subtitle rendering */
36
45
  displaySettings = { ...DEFAULT_DISPLAY_SETTINGS };
46
+ cacheLimit = 24;
47
+ prefetchBefore = 0;
48
+ prefetchAfter = 0;
49
+ onEvent;
50
+ currentRendererBackend = null;
51
+ loadedMetadataHandler = null;
52
+ seekedHandler = null;
37
53
  // WebGPU renderer (optional, falls back to WebGL2 then Canvas2D)
38
54
  webgpuRenderer = null;
39
55
  useWebGPU = false;
@@ -51,17 +67,39 @@ class BaseVideoSubtitleRenderer {
51
67
  fpsTimestamps: [],
52
68
  lastFrameTime: 0
53
69
  };
54
- constructor(options) {
70
+ constructor(options, format) {
55
71
  this.video = options.video;
72
+ this.format = format;
56
73
  this.subUrl = options.subUrl;
57
74
  this.subContent = options.subContent;
58
75
  this.onWebGPUFallback = options.onWebGPUFallback;
59
76
  this.onWebGL2Fallback = options.onWebGL2Fallback;
77
+ this.onEvent = options.onEvent;
78
+ this.displaySettings = { ...DEFAULT_DISPLAY_SETTINGS, ...options.displaySettings };
79
+ this.cacheLimit = Math.max(0, Math.floor(options.cacheLimit ?? 24));
80
+ this.prefetchBefore = Math.max(0, Math.floor(options.prefetchWindow?.before ?? 0));
81
+ this.prefetchAfter = Math.max(0, Math.floor(options.prefetchWindow?.after ?? 0));
60
82
  }
61
83
  /** Get current display settings */
62
84
  getDisplaySettings() {
63
85
  return { ...this.displaySettings };
64
86
  }
87
+ /** Get parser metadata for the active subtitle track. */
88
+ getMetadata() {
89
+ return this.parserMetadata;
90
+ }
91
+ /** Get the most recently displayed cue metadata. */
92
+ getCurrentCueMetadata() {
93
+ return this.currentCueMetadata;
94
+ }
95
+ /** Get cue metadata for the specified index. */
96
+ getCueMetadata(index) {
97
+ return this.buildCueMetadata(index);
98
+ }
99
+ /** Get the configured frame-cache limit. */
100
+ getCacheLimit() {
101
+ return this.cacheLimit;
102
+ }
65
103
  /** Get base stats common to all renderers */
66
104
  getBaseStats() {
67
105
  const now = performance.now();
@@ -84,13 +122,18 @@ class BaseVideoSubtitleRenderer {
84
122
  }
85
123
  /** Set display settings and force re-render */
86
124
  setDisplaySettings(settings) {
87
- const changed = settings.scale !== this.displaySettings.scale || settings.verticalOffset !== this.displaySettings.verticalOffset;
88
- if (settings.scale !== undefined) {
89
- this.displaySettings.scale = Math.max(0.1, Math.min(3.0, settings.scale));
90
- }
91
- if (settings.verticalOffset !== undefined) {
92
- this.displaySettings.verticalOffset = Math.max(-50, Math.min(50, settings.verticalOffset));
93
- }
125
+ const nextSettings = {
126
+ ...this.displaySettings,
127
+ ...settings
128
+ };
129
+ nextSettings.scale = Math.max(0.1, Math.min(3.0, nextSettings.scale));
130
+ nextSettings.verticalOffset = Math.max(-50, Math.min(50, nextSettings.verticalOffset));
131
+ nextSettings.horizontalOffset = Math.max(-50, Math.min(50, nextSettings.horizontalOffset));
132
+ nextSettings.bottomPadding = Math.max(0, Math.min(50, nextSettings.bottomPadding));
133
+ nextSettings.safeArea = Math.max(0, Math.min(25, nextSettings.safeArea));
134
+ nextSettings.opacity = Math.max(0, Math.min(1, nextSettings.opacity));
135
+ const changed = JSON.stringify(nextSettings) !== JSON.stringify(this.displaySettings);
136
+ this.displaySettings = nextSettings;
94
137
  // Force re-render if settings changed
95
138
  if (changed) {
96
139
  this.lastRenderedIndex = -1;
@@ -105,7 +148,9 @@ class BaseVideoSubtitleRenderer {
105
148
  }
106
149
  /** Start initialization. */
107
150
  startInit() {
108
- this.init();
151
+ this.init().catch((error) => {
152
+ this.emitEvent({ type: 'error', format: this.format, error: error instanceof Error ? error : new Error(String(error)) });
153
+ });
109
154
  }
110
155
  /** Initialize the renderer. */
111
156
  async init() {
@@ -143,12 +188,44 @@ class BaseVideoSubtitleRenderer {
143
188
  this.updateCanvasSize();
144
189
  this.resizeObserver = new ResizeObserver(() => this.updateCanvasSize());
145
190
  this.resizeObserver.observe(this.video);
146
- this.video.addEventListener('loadedmetadata', () => this.updateCanvasSize());
147
- this.video.addEventListener('seeked', () => {
191
+ this.loadedMetadataHandler = () => this.updateCanvasSize();
192
+ this.seekedHandler = () => {
148
193
  this.lastRenderedIndex = -1;
149
194
  this.lastRenderedTime = -1;
150
195
  this.onSeek();
151
- });
196
+ };
197
+ this.video.addEventListener('loadedmetadata', this.loadedMetadataHandler);
198
+ this.video.addEventListener('seeked', this.seekedHandler);
199
+ }
200
+ emitEvent(event) {
201
+ this.onEvent?.(event);
202
+ }
203
+ setParserMetadata(metadata) {
204
+ this.parserMetadata = metadata;
205
+ if (metadata) {
206
+ this.emitEvent({ type: 'loaded', format: this.format, metadata });
207
+ }
208
+ }
209
+ emitWorkerState(enabled, ready, sessionId, fallback = false) {
210
+ this.emitEvent({ type: 'worker-state', enabled, ready, sessionId, fallback });
211
+ }
212
+ emitCacheChange(cachedFrames, pendingRenders) {
213
+ this.emitEvent({ type: 'cache-change', cachedFrames, pendingRenders, cacheLimit: this.cacheLimit });
214
+ }
215
+ emitCueChange(cue) {
216
+ if (this.lastCueIndex === cue?.index && cue?.index !== undefined) {
217
+ this.currentCueMetadata = cue;
218
+ return;
219
+ }
220
+ this.lastCueIndex = cue?.index ?? null;
221
+ this.currentCueMetadata = cue;
222
+ this.emitEvent({ type: 'cue-change', cue });
223
+ }
224
+ emitRendererBackend(renderer) {
225
+ if (this.currentRendererBackend === renderer)
226
+ return;
227
+ this.currentRendererBackend = renderer;
228
+ this.emitEvent({ type: 'renderer-change', renderer });
152
229
  }
153
230
  /** Initialize WebGPU renderer. */
154
231
  async initWebGPU() {
@@ -162,10 +239,9 @@ class BaseVideoSubtitleRenderer {
162
239
  const height = Math.max(1, bounds.height * window.devicePixelRatio);
163
240
  await this.webgpuRenderer.setCanvas(this.canvas, width, height);
164
241
  this.useWebGPU = true;
165
- console.log('[libbitsub] Using WebGPU renderer');
242
+ this.emitRendererBackend('webgpu');
166
243
  }
167
244
  catch (error) {
168
- console.warn('[libbitsub] WebGPU init failed, falling back to WebGL2:', error);
169
245
  this.webgpuRenderer?.destroy();
170
246
  this.webgpuRenderer = null;
171
247
  this.useWebGPU = false;
@@ -191,10 +267,9 @@ class BaseVideoSubtitleRenderer {
191
267
  const height = Math.max(1, bounds.height * window.devicePixelRatio);
192
268
  await this.webgl2Renderer.setCanvas(this.canvas, width, height);
193
269
  this.useWebGL2 = true;
194
- console.log('[libbitsub] Using WebGL2 renderer');
270
+ this.emitRendererBackend('webgl2');
195
271
  }
196
272
  catch (error) {
197
- console.warn('[libbitsub] WebGL2 init failed, falling back to Canvas2D:', error);
198
273
  this.webgl2Renderer?.destroy();
199
274
  this.webgl2Renderer = null;
200
275
  this.useWebGL2 = false;
@@ -209,7 +284,7 @@ class BaseVideoSubtitleRenderer {
209
284
  this.ctx = this.canvas.getContext('2d');
210
285
  this.useWebGPU = false;
211
286
  this.useWebGL2 = false;
212
- console.log('[libbitsub] Using Canvas2D renderer');
287
+ this.emitRendererBackend('canvas2d');
213
288
  }
214
289
  /** Called when video seeks. */
215
290
  onSeek() { }
@@ -307,6 +382,12 @@ class BaseVideoSubtitleRenderer {
307
382
  }
308
383
  this.lastRenderedIndex = currentIndex;
309
384
  this.lastRenderedTime = currentTime;
385
+ this.emitCueChange(currentIndex >= 0 ? this.buildCueMetadata(currentIndex) : null);
386
+ this.emitEvent({ type: 'stats', stats: this.getStats() });
387
+ if (currentIndex >= 0 && (this.prefetchBefore > 0 || this.prefetchAfter > 0)) {
388
+ const prefetch = this.prefetchAroundTime;
389
+ prefetch?.call(this, currentTime).catch(() => { });
390
+ }
310
391
  }
311
392
  }
312
393
  this.animationFrameId = requestAnimationFrame(render);
@@ -341,6 +422,59 @@ class BaseVideoSubtitleRenderer {
341
422
  this.renderFrameCanvas2D(data, index);
342
423
  }
343
424
  }
425
+ computeLayout(data) {
426
+ if (!this.canvas) {
427
+ return { scaleX: 1, scaleY: 1, shiftX: 0, shiftY: 0, opacity: this.displaySettings.opacity };
428
+ }
429
+ const baseScaleX = this.canvas.width / data.width;
430
+ const baseScaleY = this.canvas.height / data.height;
431
+ const bounds = getSubtitleBounds(data);
432
+ const { scale, verticalOffset, horizontalOffset, horizontalAlign, bottomPadding, safeArea, opacity } = this.displaySettings;
433
+ if (!bounds) {
434
+ return {
435
+ scaleX: baseScaleX * scale,
436
+ scaleY: baseScaleY * scale,
437
+ shiftX: (horizontalOffset / 100) * this.canvas.width,
438
+ shiftY: (verticalOffset / 100) * this.canvas.height - (bottomPadding / 100) * this.canvas.height,
439
+ opacity
440
+ };
441
+ }
442
+ const groupWidth = bounds.width * baseScaleX;
443
+ const groupHeight = bounds.height * baseScaleY;
444
+ const scaledGroupWidth = groupWidth * scale;
445
+ const scaledGroupHeight = groupHeight * scale;
446
+ let anchorShiftX = 0;
447
+ if (horizontalAlign === 'center') {
448
+ anchorShiftX = (groupWidth - scaledGroupWidth) / 2;
449
+ }
450
+ else if (horizontalAlign === 'right') {
451
+ anchorShiftX = groupWidth - scaledGroupWidth;
452
+ }
453
+ let shiftX = anchorShiftX + (horizontalOffset / 100) * this.canvas.width;
454
+ let shiftY = groupHeight - scaledGroupHeight + (verticalOffset / 100) * this.canvas.height;
455
+ shiftY -= (bottomPadding / 100) * this.canvas.height;
456
+ const safeX = (safeArea / 100) * this.canvas.width;
457
+ const safeY = (safeArea / 100) * this.canvas.height;
458
+ const finalMinX = bounds.x * baseScaleX + shiftX;
459
+ const finalMinY = bounds.y * baseScaleY + shiftY;
460
+ const finalMaxX = finalMinX + scaledGroupWidth;
461
+ const finalMaxY = finalMinY + scaledGroupHeight;
462
+ if (finalMinX < safeX)
463
+ shiftX += safeX - finalMinX;
464
+ if (finalMaxX > this.canvas.width - safeX)
465
+ shiftX -= finalMaxX - (this.canvas.width - safeX);
466
+ if (finalMinY < safeY)
467
+ shiftY += safeY - finalMinY;
468
+ if (finalMaxY > this.canvas.height - safeY)
469
+ shiftY -= finalMaxY - (this.canvas.height - safeY);
470
+ return {
471
+ scaleX: baseScaleX * scale,
472
+ scaleY: baseScaleY * scale,
473
+ shiftX,
474
+ shiftY,
475
+ opacity
476
+ };
477
+ }
344
478
  /** Render using WebGPU. */
345
479
  renderFrameWebGPU(data, index) {
346
480
  if (!this.webgpuRenderer || !this.canvas)
@@ -354,14 +488,8 @@ class BaseVideoSubtitleRenderer {
354
488
  // Store for potential reuse
355
489
  this.lastRenderedData = data;
356
490
  // Calculate base scale factors
357
- const baseScaleX = this.canvas.width / data.width;
358
- const baseScaleY = this.canvas.height / data.height;
359
- // Apply display settings
360
- const { scale, verticalOffset } = this.displaySettings;
361
- const scaleX = baseScaleX * scale;
362
- const scaleY = baseScaleY * scale;
363
- const offsetY = (verticalOffset / 100) * this.canvas.height;
364
- this.webgpuRenderer.render(data.compositionData, data.width, data.height, scaleX, scaleY, offsetY);
491
+ const layout = this.computeLayout(data);
492
+ this.webgpuRenderer.render(data.compositionData, data.width, data.height, layout.scaleX, layout.scaleY, layout.shiftX, layout.shiftY, layout.opacity);
365
493
  }
366
494
  /** Render using WebGL2. */
367
495
  renderFrameWebGL2(data, index) {
@@ -373,13 +501,8 @@ class BaseVideoSubtitleRenderer {
373
501
  return;
374
502
  }
375
503
  this.lastRenderedData = data;
376
- const baseScaleX = this.canvas.width / data.width;
377
- const baseScaleY = this.canvas.height / data.height;
378
- const { scale, verticalOffset } = this.displaySettings;
379
- const scaleX = baseScaleX * scale;
380
- const scaleY = baseScaleY * scale;
381
- const offsetY = (verticalOffset / 100) * this.canvas.height;
382
- this.webgl2Renderer.render(data.compositionData, data.width, data.height, scaleX, scaleY, offsetY);
504
+ const layout = this.computeLayout(data);
505
+ this.webgl2Renderer.render(data.compositionData, data.width, data.height, layout.scaleX, layout.scaleY, layout.shiftX, layout.shiftY, layout.opacity);
383
506
  }
384
507
  /** Render using Canvas2D. */
385
508
  renderFrameCanvas2D(data, index) {
@@ -394,14 +517,9 @@ class BaseVideoSubtitleRenderer {
394
517
  }
395
518
  // Store for potential reuse
396
519
  this.lastRenderedData = data;
397
- // Calculate base scale factors
398
- const baseScaleX = this.canvas.width / data.width;
399
- const baseScaleY = this.canvas.height / data.height;
400
- // Apply display settings
401
- const { scale, verticalOffset } = this.displaySettings;
402
- const scaleX = baseScaleX * scale;
403
- const scaleY = baseScaleY * scale;
404
- const offsetY = (verticalOffset / 100) * this.canvas.height;
520
+ const layout = this.computeLayout(data);
521
+ this.ctx.save();
522
+ this.ctx.globalAlpha = layout.opacity;
405
523
  for (const comp of data.compositionData) {
406
524
  if (!this.tempCanvas || !this.tempCtx)
407
525
  continue;
@@ -413,14 +531,13 @@ class BaseVideoSubtitleRenderer {
413
531
  this.tempCtx.putImageData(comp.pixelData, 0, 0);
414
532
  // Calculate position with scale and offset applied
415
533
  // Center the scaled content horizontally
416
- const scaledWidth = comp.pixelData.width * scaleX;
417
- const scaledHeight = comp.pixelData.height * scaleY;
418
- const baseX = comp.x * baseScaleX;
419
- const baseY = comp.y * baseScaleY;
420
- const centeredX = baseX + (comp.pixelData.width * baseScaleX - scaledWidth) / 2;
421
- const adjustedY = baseY + offsetY + (comp.pixelData.height * baseScaleY - scaledHeight);
422
- this.ctx.drawImage(this.tempCanvas, centeredX, adjustedY, scaledWidth, scaledHeight);
534
+ const scaledWidth = comp.pixelData.width * layout.scaleX;
535
+ const scaledHeight = comp.pixelData.height * layout.scaleY;
536
+ const adjustedX = comp.x * (this.canvas.width / data.width) + layout.shiftX;
537
+ const adjustedY = comp.y * (this.canvas.height / data.height) + layout.shiftY;
538
+ this.ctx.drawImage(this.tempCanvas, adjustedX, adjustedY, scaledWidth, scaledHeight);
423
539
  }
540
+ this.ctx.restore();
424
541
  }
425
542
  /** Dispose of all resources. */
426
543
  dispose() {
@@ -431,6 +548,14 @@ class BaseVideoSubtitleRenderer {
431
548
  }
432
549
  this.resizeObserver?.disconnect();
433
550
  this.resizeObserver = null;
551
+ if (this.loadedMetadataHandler) {
552
+ this.video.removeEventListener('loadedmetadata', this.loadedMetadataHandler);
553
+ this.loadedMetadataHandler = null;
554
+ }
555
+ if (this.seekedHandler) {
556
+ this.video.removeEventListener('seeked', this.seekedHandler);
557
+ this.seekedHandler = null;
558
+ }
434
559
  // Clean up GPU renderers
435
560
  if (this.webgpuRenderer) {
436
561
  this.webgpuRenderer.destroy();
@@ -446,6 +571,8 @@ class BaseVideoSubtitleRenderer {
446
571
  this.tempCanvas = null;
447
572
  this.tempCtx = null;
448
573
  this.lastRenderedData = null;
574
+ this.currentCueMetadata = null;
575
+ this.parserMetadata = null;
449
576
  this.useWebGPU = false;
450
577
  this.useWebGL2 = false;
451
578
  }
@@ -461,14 +588,16 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
461
588
  onLoaded;
462
589
  onError;
463
590
  constructor(options) {
464
- super(options);
591
+ super(options, 'pgs');
465
592
  this.onLoading = options.onLoading;
466
593
  this.onLoaded = options.onLoaded;
467
594
  this.onError = options.onError;
595
+ applyCacheLimit(this.state, this.cacheLimit);
468
596
  this.startInit();
469
597
  }
470
598
  async loadSubtitles() {
471
599
  try {
600
+ this.emitEvent({ type: 'loading', format: 'pgs' });
472
601
  this.onLoading?.();
473
602
  let arrayBuffer;
474
603
  if (this.subContent) {
@@ -486,16 +615,24 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
486
615
  const data = new Uint8Array(arrayBuffer);
487
616
  if (this.state.useWorker) {
488
617
  try {
618
+ this.state.sessionId = createWorkerSessionId();
489
619
  await getOrCreateWorker();
490
- const loadResponse = await sendToWorker({ type: 'loadPgs', data: data.buffer.slice(0) });
620
+ this.emitWorkerState(true, false, this.state.sessionId);
621
+ const loadResponse = await sendToWorker({
622
+ type: 'loadPgs',
623
+ sessionId: this.state.sessionId,
624
+ data: data.buffer.slice(0)
625
+ });
491
626
  if (loadResponse.type === 'pgsLoaded') {
492
627
  this.state.workerReady = true;
493
- const tsResponse = await sendToWorker({ type: 'getPgsTimestamps' });
628
+ this.state.metadata = loadResponse.metadata;
629
+ const tsResponse = await sendToWorker({ type: 'getPgsTimestamps', sessionId: this.state.sessionId });
494
630
  if (tsResponse.type === 'pgsTimestamps') {
495
631
  this.state.timestamps = tsResponse.timestamps;
496
632
  }
497
633
  this.isLoaded = true;
498
- console.log(`[libbitsub] PGS loaded (worker): ${loadResponse.count} display sets from ${loadResponse.byteLength} bytes`);
634
+ this.setParserMetadata(loadResponse.metadata);
635
+ this.emitWorkerState(true, true, this.state.sessionId);
499
636
  this.onLoaded?.();
500
637
  return; // Success, don't fall through to main thread
501
638
  }
@@ -504,8 +641,8 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
504
641
  }
505
642
  }
506
643
  catch (workerError) {
507
- console.warn('[libbitsub] Worker failed, falling back to main thread:', workerError);
508
644
  this.state.useWorker = false;
645
+ this.emitWorkerState(false, false, this.state.sessionId, true);
509
646
  }
510
647
  }
511
648
  // Main thread fallback - use idle callback to avoid blocking UI
@@ -513,8 +650,9 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
513
650
  this.onLoaded?.();
514
651
  }
515
652
  catch (error) {
516
- console.error('Failed to load PGS subtitles:', error);
517
- this.onError?.(error instanceof Error ? error : new Error(String(error)));
653
+ const resolvedError = error instanceof Error ? error : new Error(String(error));
654
+ this.emitEvent({ type: 'error', format: 'pgs', error: resolvedError });
655
+ this.onError?.(resolvedError);
518
656
  }
519
657
  }
520
658
  async loadOnMainThread(data) {
@@ -530,8 +668,9 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
530
668
  scheduleTask(() => {
531
669
  const count = this.pgsParser.load(data);
532
670
  this.state.timestamps = this.pgsParser.getTimestamps();
671
+ this.state.metadata = this.pgsParser.getMetadata();
533
672
  this.isLoaded = true;
534
- console.log(`[libbitsub] PGS loaded (main thread): ${count} display sets from ${data.byteLength} bytes`);
673
+ this.setParserMetadata(this.state.metadata);
535
674
  resolve();
536
675
  });
537
676
  });
@@ -557,16 +696,22 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
557
696
  return this.pgsParser?.findIndexAtTimestamp(time) ?? -1;
558
697
  }
559
698
  renderAtIndex(index) {
699
+ if (this.state.frameCache.has(index)) {
700
+ return this.state.frameCache.get(index) ?? undefined;
701
+ }
560
702
  if (this.state.useWorker && this.state.workerReady) {
561
- if (this.state.frameCache.has(index)) {
562
- return this.state.frameCache.get(index) ?? undefined;
563
- }
564
703
  if (!this.state.pendingRenders.has(index)) {
565
- const renderPromise = sendToWorker({ type: 'renderPgsAtIndex', index }).then((response) => response.type === 'pgsFrame' && response.frame ? convertFrameData(response.frame) : null);
704
+ const renderPromise = sendToWorker({
705
+ type: 'renderPgsAtIndex',
706
+ sessionId: this.state.sessionId,
707
+ index
708
+ }).then((response) => (response.type === 'pgsFrame' && response.frame ? convertFrameData(response.frame) : null));
566
709
  this.state.pendingRenders.set(index, renderPromise);
710
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
567
711
  renderPromise.then((result) => {
568
- this.state.frameCache.set(index, result);
712
+ setCachedFrame(this.state, index, result);
569
713
  this.state.pendingRenders.delete(index);
714
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
570
715
  // Force re-render on next frame by resetting lastRenderedIndex
571
716
  if (this.findCurrentIndex(this.video.currentTime) === index) {
572
717
  this.lastRenderedIndex = -1;
@@ -576,7 +721,32 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
576
721
  // Return undefined to indicate async loading in progress
577
722
  return undefined;
578
723
  }
579
- return this.pgsParser?.renderAtIndex(index);
724
+ const rendered = this.pgsParser?.renderAtIndex(index) ?? null;
725
+ setCachedFrame(this.state, index, rendered);
726
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
727
+ return rendered ?? undefined;
728
+ }
729
+ buildCueMetadata(index) {
730
+ if (this.pgsParser) {
731
+ return this.pgsParser.getCueMetadata(index);
732
+ }
733
+ const metadata = this.state.metadata;
734
+ if (!metadata || index < 0 || index >= this.state.timestamps.length)
735
+ return null;
736
+ const startTime = this.state.timestamps[index];
737
+ const endTime = this.state.timestamps[index + 1] ?? startTime + 5000;
738
+ const frame = this.state.frameCache.get(index) ?? null;
739
+ return {
740
+ index,
741
+ format: 'pgs',
742
+ startTime,
743
+ endTime,
744
+ duration: Math.max(0, endTime - startTime),
745
+ screenWidth: metadata.screenWidth,
746
+ screenHeight: metadata.screenHeight,
747
+ bounds: frame ? getSubtitleBounds(frame) : null,
748
+ compositionCount: frame?.compositionData.length ?? 0
749
+ };
580
750
  }
581
751
  isPendingRender(index) {
582
752
  return this.state.pendingRenders.has(index);
@@ -584,10 +754,43 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
584
754
  onSeek() {
585
755
  this.state.frameCache.clear();
586
756
  this.state.pendingRenders.clear();
757
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
758
+ if (this.state.useWorker && this.state.workerReady) {
759
+ sendToWorker({ type: 'clearPgsCache', sessionId: this.state.sessionId }).catch(() => { });
760
+ }
761
+ this.pgsParser?.clearCache();
762
+ }
763
+ setCacheLimit(limit) {
764
+ this.cacheLimit = applyCacheLimit(this.state, limit);
765
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
766
+ }
767
+ clearFrameCache() {
768
+ this.state.frameCache.clear();
769
+ this.state.pendingRenders.clear();
770
+ this.lastRenderedIndex = -1;
587
771
  if (this.state.useWorker && this.state.workerReady) {
588
- sendToWorker({ type: 'clearPgsCache' }).catch(() => { });
772
+ sendToWorker({ type: 'clearPgsCache', sessionId: this.state.sessionId }).catch(() => { });
589
773
  }
590
774
  this.pgsParser?.clearCache();
775
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
776
+ }
777
+ async prefetchRange(startIndex, endIndex) {
778
+ const safeStart = Math.max(0, Math.min(startIndex, endIndex));
779
+ const safeEnd = Math.min(Math.max(startIndex, endIndex), this.state.timestamps.length - 1);
780
+ for (let index = safeStart; index <= safeEnd; index++) {
781
+ if (this.state.frameCache.has(index))
782
+ continue;
783
+ const result = this.renderAtIndex(index);
784
+ if (result === undefined && this.state.pendingRenders.has(index)) {
785
+ await this.state.pendingRenders.get(index);
786
+ }
787
+ }
788
+ }
789
+ async prefetchAroundTime(time, before = this.prefetchBefore, after = this.prefetchAfter) {
790
+ const currentIndex = this.findCurrentIndex(time);
791
+ if (currentIndex < 0)
792
+ return;
793
+ await this.prefetchRange(currentIndex - before, currentIndex + after);
591
794
  }
592
795
  /** Get performance statistics for PGS renderer */
593
796
  getStats() {
@@ -605,10 +808,11 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
605
808
  this.state.frameCache.clear();
606
809
  this.state.pendingRenders.clear();
607
810
  if (this.state.useWorker && this.state.workerReady) {
608
- sendToWorker({ type: 'disposePgs' }).catch(() => { });
811
+ sendToWorker({ type: 'disposePgs', sessionId: this.state.sessionId }).catch(() => { });
609
812
  }
610
813
  this.pgsParser?.dispose();
611
814
  this.pgsParser = null;
815
+ this.state.sessionId = null;
612
816
  }
613
817
  }
614
818
  /**
@@ -628,18 +832,19 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
628
832
  cachedIndexTime = -1;
629
833
  pendingIndexLookup = null;
630
834
  constructor(options) {
631
- super(options);
835
+ super(options, 'vobsub');
632
836
  this.idxUrl = options.idxUrl || (options.subUrl ? options.subUrl.replace(/\\.sub$/i, '.idx') : undefined);
633
837
  this.idxContent = options.idxContent;
634
838
  this.onLoading = options.onLoading;
635
839
  this.onLoaded = options.onLoaded;
636
840
  this.onError = options.onError;
841
+ applyCacheLimit(this.state, this.cacheLimit);
637
842
  this.startInit();
638
843
  }
639
844
  async loadSubtitles() {
640
845
  try {
846
+ this.emitEvent({ type: 'loading', format: 'vobsub' });
641
847
  this.onLoading?.();
642
- console.log(`[libbitsub] Loading VobSub`);
643
848
  let subArrayBuffer;
644
849
  let idxData;
645
850
  // Resolve SUB content
@@ -685,23 +890,27 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
685
890
  throw new Error('Failed to load VobSub data');
686
891
  }
687
892
  const subData = new Uint8Array(subArrayBuffer);
688
- console.log(`[libbitsub] VobSub files loaded: .sub=${subArrayBuffer.byteLength} bytes, .idx=${idxData.length} chars`);
689
893
  if (this.state.useWorker) {
690
894
  try {
895
+ this.state.sessionId = createWorkerSessionId();
691
896
  await getOrCreateWorker();
897
+ this.emitWorkerState(true, false, this.state.sessionId);
692
898
  const loadResponse = await sendToWorker({
693
899
  type: 'loadVobSub',
900
+ sessionId: this.state.sessionId,
694
901
  idxContent: idxData,
695
902
  subData: subData.buffer.slice(0)
696
903
  });
697
904
  if (loadResponse.type === 'vobSubLoaded') {
698
905
  this.state.workerReady = true;
699
- const tsResponse = await sendToWorker({ type: 'getVobSubTimestamps' });
906
+ this.state.metadata = loadResponse.metadata;
907
+ const tsResponse = await sendToWorker({ type: 'getVobSubTimestamps', sessionId: this.state.sessionId });
700
908
  if (tsResponse.type === 'vobSubTimestamps') {
701
909
  this.state.timestamps = tsResponse.timestamps;
702
910
  }
703
911
  this.isLoaded = true;
704
- console.log(`[libbitsub] VobSub loaded (worker): ${loadResponse.count} subtitle entries`);
912
+ this.setParserMetadata(loadResponse.metadata);
913
+ this.emitWorkerState(true, true, this.state.sessionId);
705
914
  this.onLoaded?.();
706
915
  return; // Success, don't fall through to main thread
707
916
  }
@@ -710,8 +919,8 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
710
919
  }
711
920
  }
712
921
  catch (workerError) {
713
- console.warn('[libbitsub] Worker failed, falling back to main thread:', workerError);
714
922
  this.state.useWorker = false;
923
+ this.emitWorkerState(false, false, this.state.sessionId, true);
715
924
  }
716
925
  }
717
926
  // Main thread fallback
@@ -719,8 +928,9 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
719
928
  this.onLoaded?.();
720
929
  }
721
930
  catch (error) {
722
- console.error('Failed to load VobSub subtitles:', error);
723
- this.onError?.(error instanceof Error ? error : new Error(String(error)));
931
+ const resolvedError = error instanceof Error ? error : new Error(String(error));
932
+ this.emitEvent({ type: 'error', format: 'vobsub', error: resolvedError });
933
+ this.onError?.(resolvedError);
724
934
  }
725
935
  }
726
936
  async loadOnMainThread(idxData, subData) {
@@ -735,8 +945,9 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
735
945
  scheduleTask(() => {
736
946
  this.vobsubParser.loadFromData(idxData, subData);
737
947
  this.state.timestamps = this.vobsubParser.getTimestamps();
738
- console.log(`[libbitsub] VobSub loaded (main thread): ${this.vobsubParser.count} subtitle entries`);
948
+ this.state.metadata = this.vobsubParser.getMetadata();
739
949
  this.isLoaded = true;
950
+ this.setParserMetadata(this.state.metadata);
740
951
  resolve();
741
952
  });
742
953
  });
@@ -764,7 +975,11 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
764
975
  }
765
976
  // Start async lookup if not already pending
766
977
  if (!this.pendingIndexLookup) {
767
- this.pendingIndexLookup = sendToWorker({ type: 'findVobSubIndex', timeMs }).then((response) => {
978
+ this.pendingIndexLookup = sendToWorker({
979
+ type: 'findVobSubIndex',
980
+ sessionId: this.state.sessionId,
981
+ timeMs
982
+ }).then((response) => {
768
983
  if (response.type === 'vobSubIndex') {
769
984
  const newIndex = response.index;
770
985
  const oldIndex = this.cachedIndex;
@@ -784,18 +999,23 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
784
999
  return this.vobsubParser?.findIndexAtTimestamp(time) ?? -1;
785
1000
  }
786
1001
  renderAtIndex(index) {
1002
+ if (this.state.frameCache.has(index)) {
1003
+ return this.state.frameCache.get(index) ?? undefined;
1004
+ }
787
1005
  if (this.state.useWorker && this.state.workerReady) {
788
- // Return cached frame immediately if available
789
- if (this.state.frameCache.has(index)) {
790
- return this.state.frameCache.get(index) ?? undefined;
791
- }
792
1006
  // Start async render if not already pending
793
1007
  if (!this.state.pendingRenders.has(index)) {
794
- const renderPromise = sendToWorker({ type: 'renderVobSubAtIndex', index }).then((response) => response.type === 'vobSubFrame' && response.frame ? convertFrameData(response.frame) : null);
1008
+ const renderPromise = sendToWorker({
1009
+ type: 'renderVobSubAtIndex',
1010
+ sessionId: this.state.sessionId,
1011
+ index
1012
+ }).then((response) => (response.type === 'vobSubFrame' && response.frame ? convertFrameData(response.frame) : null));
795
1013
  this.state.pendingRenders.set(index, renderPromise);
1014
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
796
1015
  renderPromise.then((result) => {
797
- this.state.frameCache.set(index, result);
1016
+ setCachedFrame(this.state, index, result);
798
1017
  this.state.pendingRenders.delete(index);
1018
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
799
1019
  // Force re-render on next frame by resetting lastRenderedIndex
800
1020
  if (this.findCurrentIndex(this.video.currentTime) === index) {
801
1021
  this.lastRenderedIndex = -1;
@@ -805,7 +1025,34 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
805
1025
  // Return undefined to indicate async loading in progress
806
1026
  return undefined;
807
1027
  }
808
- return this.vobsubParser?.renderAtIndex(index);
1028
+ const rendered = this.vobsubParser?.renderAtIndex(index) ?? null;
1029
+ setCachedFrame(this.state, index, rendered);
1030
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
1031
+ return rendered ?? undefined;
1032
+ }
1033
+ buildCueMetadata(index) {
1034
+ if (this.vobsubParser) {
1035
+ return this.vobsubParser.getCueMetadata(index);
1036
+ }
1037
+ const metadata = this.state.metadata;
1038
+ if (!metadata || index < 0 || index >= this.state.timestamps.length)
1039
+ return null;
1040
+ const startTime = this.state.timestamps[index];
1041
+ const endTime = this.state.timestamps[index + 1] ?? startTime + 5000;
1042
+ const frame = this.state.frameCache.get(index) ?? null;
1043
+ return {
1044
+ index,
1045
+ format: 'vobsub',
1046
+ startTime,
1047
+ endTime,
1048
+ duration: Math.max(0, endTime - startTime),
1049
+ screenWidth: metadata.screenWidth,
1050
+ screenHeight: metadata.screenHeight,
1051
+ bounds: frame ? getSubtitleBounds(frame) : null,
1052
+ compositionCount: frame?.compositionData.length ?? 0,
1053
+ language: metadata.language ?? null,
1054
+ trackId: metadata.trackId ?? null
1055
+ };
809
1056
  }
810
1057
  isPendingRender(index) {
811
1058
  return this.state.pendingRenders.has(index);
@@ -818,9 +1065,45 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
818
1065
  this.cachedIndexTime = -1;
819
1066
  this.pendingIndexLookup = null;
820
1067
  if (this.state.useWorker && this.state.workerReady) {
821
- sendToWorker({ type: 'clearVobSubCache' }).catch(() => { });
1068
+ sendToWorker({ type: 'clearVobSubCache', sessionId: this.state.sessionId }).catch(() => { });
1069
+ }
1070
+ this.vobsubParser?.clearCache();
1071
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
1072
+ }
1073
+ setCacheLimit(limit) {
1074
+ this.cacheLimit = applyCacheLimit(this.state, limit);
1075
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
1076
+ }
1077
+ clearFrameCache() {
1078
+ this.state.frameCache.clear();
1079
+ this.state.pendingRenders.clear();
1080
+ this.cachedIndex = -1;
1081
+ this.cachedIndexTime = -1;
1082
+ this.pendingIndexLookup = null;
1083
+ this.lastRenderedIndex = -1;
1084
+ if (this.state.useWorker && this.state.workerReady) {
1085
+ sendToWorker({ type: 'clearVobSubCache', sessionId: this.state.sessionId }).catch(() => { });
822
1086
  }
823
1087
  this.vobsubParser?.clearCache();
1088
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
1089
+ }
1090
+ async prefetchRange(startIndex, endIndex) {
1091
+ const safeStart = Math.max(0, Math.min(startIndex, endIndex));
1092
+ const safeEnd = Math.min(Math.max(startIndex, endIndex), this.state.timestamps.length - 1);
1093
+ for (let index = safeStart; index <= safeEnd; index++) {
1094
+ if (this.state.frameCache.has(index))
1095
+ continue;
1096
+ const result = this.renderAtIndex(index);
1097
+ if (result === undefined && this.state.pendingRenders.has(index)) {
1098
+ await this.state.pendingRenders.get(index);
1099
+ }
1100
+ }
1101
+ }
1102
+ async prefetchAroundTime(time, before = this.prefetchBefore, after = this.prefetchAfter) {
1103
+ const currentIndex = this.findCurrentIndex(time);
1104
+ if (currentIndex < 0)
1105
+ return;
1106
+ await this.prefetchRange(currentIndex - before, currentIndex + after);
824
1107
  }
825
1108
  /** Get performance statistics for VobSub renderer */
826
1109
  getStats() {
@@ -836,32 +1119,35 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
836
1119
  /** Enable or disable debanding filter */
837
1120
  setDebandEnabled(enabled) {
838
1121
  if (this.state.useWorker && this.state.workerReady) {
839
- sendToWorker({ type: 'setVobSubDebandEnabled', enabled }).catch(() => { });
1122
+ sendToWorker({ type: 'setVobSubDebandEnabled', sessionId: this.state.sessionId, enabled }).catch(() => { });
840
1123
  }
841
1124
  this.vobsubParser?.setDebandEnabled(enabled);
842
1125
  // Clear cache to force re-render with new settings
843
1126
  this.state.frameCache.clear();
844
1127
  this.lastRenderedIndex = -1;
1128
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
845
1129
  }
846
1130
  /** Set debanding threshold (0-255, default: 64) */
847
1131
  setDebandThreshold(threshold) {
848
1132
  if (this.state.useWorker && this.state.workerReady) {
849
- sendToWorker({ type: 'setVobSubDebandThreshold', threshold }).catch(() => { });
1133
+ sendToWorker({ type: 'setVobSubDebandThreshold', sessionId: this.state.sessionId, threshold }).catch(() => { });
850
1134
  }
851
1135
  this.vobsubParser?.setDebandThreshold(threshold);
852
1136
  // Clear cache to force re-render with new settings
853
1137
  this.state.frameCache.clear();
854
1138
  this.lastRenderedIndex = -1;
1139
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
855
1140
  }
856
1141
  /** Set debanding sample range in pixels (1-64, default: 15) */
857
1142
  setDebandRange(range) {
858
1143
  if (this.state.useWorker && this.state.workerReady) {
859
- sendToWorker({ type: 'setVobSubDebandRange', range }).catch(() => { });
1144
+ sendToWorker({ type: 'setVobSubDebandRange', sessionId: this.state.sessionId, range }).catch(() => { });
860
1145
  }
861
1146
  this.vobsubParser?.setDebandRange(range);
862
1147
  // Clear cache to force re-render with new settings
863
1148
  this.state.frameCache.clear();
864
1149
  this.lastRenderedIndex = -1;
1150
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
865
1151
  }
866
1152
  /** Check if debanding is enabled */
867
1153
  get debandEnabled() {
@@ -872,10 +1158,28 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
872
1158
  this.state.frameCache.clear();
873
1159
  this.state.pendingRenders.clear();
874
1160
  if (this.state.useWorker && this.state.workerReady) {
875
- sendToWorker({ type: 'disposeVobSub' }).catch(() => { });
1161
+ sendToWorker({ type: 'disposeVobSub', sessionId: this.state.sessionId }).catch(() => { });
876
1162
  }
877
1163
  this.vobsubParser?.dispose();
878
1164
  this.vobsubParser = null;
1165
+ this.state.sessionId = null;
1166
+ }
1167
+ }
1168
+ /** Create a video subtitle renderer with automatic format detection. */
1169
+ export function createAutoSubtitleRenderer(options) {
1170
+ const format = detectSubtitleFormat({
1171
+ data: options.subContent,
1172
+ idxContent: options.idxContent,
1173
+ fileName: options.fileName,
1174
+ subUrl: options.subUrl,
1175
+ idxUrl: options.idxUrl
1176
+ });
1177
+ if (format === 'pgs') {
1178
+ return new PgsRenderer(options);
1179
+ }
1180
+ if (format === 'vobsub') {
1181
+ return new VobSubRenderer(options);
879
1182
  }
1183
+ throw new Error('Unable to detect subtitle format for video renderer');
880
1184
  }
881
1185
  //# sourceMappingURL=renderers.js.map