libbitsub 1.11.0 → 1.12.0

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 (49) hide show
  1. package/README.md +113 -16
  2. package/dist/index.d.ts +3 -3
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +3 -3
  5. package/dist/index.js.map +1 -1
  6. package/dist/integrations/react.d.ts.map +1 -1
  7. package/dist/integrations/react.js +7 -1
  8. package/dist/integrations/react.js.map +1 -1
  9. package/dist/integrations/shared.d.ts +2 -2
  10. package/dist/integrations/shared.d.ts.map +1 -1
  11. package/dist/integrations/shared.js.map +1 -1
  12. package/dist/ts/capabilities.d.ts +8 -0
  13. package/dist/ts/capabilities.d.ts.map +1 -0
  14. package/dist/ts/capabilities.js +105 -0
  15. package/dist/ts/capabilities.js.map +1 -0
  16. package/dist/ts/parsers.d.ts +39 -2
  17. package/dist/ts/parsers.d.ts.map +1 -1
  18. package/dist/ts/parsers.js +229 -1
  19. package/dist/ts/parsers.js.map +1 -1
  20. package/dist/ts/renderers.d.ts +128 -10
  21. package/dist/ts/renderers.d.ts.map +1 -1
  22. package/dist/ts/renderers.js +1341 -138
  23. package/dist/ts/renderers.js.map +1 -1
  24. package/dist/ts/types.d.ts +159 -6
  25. package/dist/ts/types.d.ts.map +1 -1
  26. package/dist/ts/utils.d.ts.map +1 -1
  27. package/dist/ts/utils.js +98 -2
  28. package/dist/ts/utils.js.map +1 -1
  29. package/dist/ts/video-frame-scheduler.d.ts +35 -0
  30. package/dist/ts/video-frame-scheduler.d.ts.map +1 -0
  31. package/dist/ts/video-frame-scheduler.js +98 -0
  32. package/dist/ts/video-frame-scheduler.js.map +1 -0
  33. package/dist/ts/worker.d.ts.map +1 -1
  34. package/dist/ts/worker.js +394 -0
  35. package/dist/ts/worker.js.map +1 -1
  36. package/dist/wrapper.d.ts +4 -3
  37. package/dist/wrapper.d.ts.map +1 -1
  38. package/dist/wrapper.js +3 -2
  39. package/dist/wrapper.js.map +1 -1
  40. package/package.json +3 -2
  41. package/pkg/README.md +113 -16
  42. package/pkg/libbitsub.d.ts +51 -1
  43. package/pkg/libbitsub.js +178 -3
  44. package/pkg/libbitsub_bg.wasm +0 -0
  45. package/pkg/libbitsub_bg.wasm.d.ts +21 -0
  46. package/pkg/package.json +4 -4
  47. package/src/integrations/react.tsx +7 -1
  48. package/src/integrations/shared.ts +2 -1
  49. package/src/wrapper.ts +22 -2
@@ -6,10 +6,12 @@ import { SubtitleDiagnosticError, createSubtitleDiagnosticError, createSubtitleW
6
6
  import { fetchSubtitleAsset, fetchSubtitleText } from './range-loader';
7
7
  import { initWasm } from './wasm';
8
8
  import { getOrCreateWorker, sendToWorker } from './worker';
9
+ import { canUseWorkerOffscreenRender } from './capabilities';
9
10
  import { binarySearchTimestamp, convertFrameData, createWorkerSessionId, createWorkerState, detectSubtitleFormat, getSubtitleBounds, isMksSource, setCacheLimit as applyCacheLimit, setCachedFrame } from './utils';
10
- import { PgsParser, VobSubParserLowLevel } from './parsers';
11
+ import { PgsParser, DvbParser, VobSubParserLowLevel } from './parsers';
11
12
  import { WebGPURenderer, isWebGPUSupported } from './webgpu-renderer';
12
13
  import { WebGL2Renderer, isWebGL2Supported } from './webgl2-renderer';
14
+ import { VideoFrameScheduler, supportsFrameAwareSync } from './video-frame-scheduler';
13
15
  /** Default display settings */
14
16
  const DEFAULT_DISPLAY_SETTINGS = {
15
17
  scale: 1.0,
@@ -40,8 +42,14 @@ class BaseVideoSubtitleRenderer {
40
42
  subUrl;
41
43
  subContent;
42
44
  canvas = null;
45
+ providedCanvas;
46
+ requestedContainer;
47
+ overlayContainer = null;
48
+ ownsCanvas = false;
49
+ devicePixelRatioCap;
50
+ backendOption;
43
51
  ctx = null;
44
- animationFrameId = null;
52
+ frameScheduler = null;
45
53
  isLoaded = false;
46
54
  lastRenderedIndex = -1;
47
55
  lastRenderedTime = -1;
@@ -55,8 +63,7 @@ class BaseVideoSubtitleRenderer {
55
63
  parserMetadata = null;
56
64
  /** Display settings for subtitle rendering */
57
65
  displaySettings = { ...DEFAULT_DISPLAY_SETTINGS };
58
- /** Time offset in seconds added to video.currentTime for subtitle lookup */
59
- timeOffset = 0;
66
+ _timeOffset = 0;
60
67
  cacheLimit = 24;
61
68
  prefetchBefore = 0;
62
69
  prefetchAfter = 0;
@@ -69,6 +76,7 @@ class BaseVideoSubtitleRenderer {
69
76
  lastRenderInfo = null;
70
77
  loadedMetadataHandler = null;
71
78
  seekedHandler = null;
79
+ initPromise = null;
72
80
  // WebGPU renderer (optional, falls back to WebGL2 then Canvas2D)
73
81
  webgpuRenderer = null;
74
82
  useWebGPU = false;
@@ -77,6 +85,18 @@ class BaseVideoSubtitleRenderer {
77
85
  webgl2Renderer = null;
78
86
  useWebGL2 = false;
79
87
  onWebGL2Fallback;
88
+ offscreenRenderOption;
89
+ frameAwareSync;
90
+ pendingWorkerOffscreen = false;
91
+ useWorkerOffscreen = false;
92
+ canvasPixelWidth = 1;
93
+ canvasPixelHeight = 1;
94
+ presentationToken = 0;
95
+ offscreenAttachPromise = null;
96
+ offscreenTransferPending = false;
97
+ offscreenFrameMetadata = new Map();
98
+ lastSynchronizedTime = null;
99
+ lastPresentedFrames = null;
80
100
  // Performance tracking
81
101
  perfStats = {
82
102
  framesRendered: 0,
@@ -91,8 +111,19 @@ class BaseVideoSubtitleRenderer {
91
111
  this.format = format;
92
112
  this.subUrl = options.subUrl;
93
113
  this.subContent = options.subContent;
114
+ this.providedCanvas = options.canvas ?? null;
115
+ this.requestedContainer = options.container ?? null;
116
+ this.devicePixelRatioCap =
117
+ options.devicePixelRatioCap !== undefined &&
118
+ Number.isFinite(options.devicePixelRatioCap) &&
119
+ options.devicePixelRatioCap > 0
120
+ ? options.devicePixelRatioCap
121
+ : null;
122
+ this.backendOption = options.backend ?? 'auto';
94
123
  this.onWebGPUFallback = options.onWebGPUFallback;
95
124
  this.onWebGL2Fallback = options.onWebGL2Fallback;
125
+ this.offscreenRenderOption = options.offscreenRender !== false;
126
+ this.frameAwareSync = options.frameAwareSync !== false;
96
127
  this.onEvent = options.onEvent;
97
128
  this.onWarning = options.onWarning;
98
129
  this.debug = Boolean(options.debug);
@@ -132,6 +163,20 @@ class BaseVideoSubtitleRenderer {
132
163
  getDisplaySettings() {
133
164
  return { ...this.displaySettings };
134
165
  }
166
+ /** Time offset in seconds added to the active video clock for subtitle lookup. */
167
+ get timeOffset() {
168
+ return this._timeOffset;
169
+ }
170
+ set timeOffset(value) {
171
+ if (value === this._timeOffset)
172
+ return;
173
+ this._timeOffset = value;
174
+ this.invalidatePresentation();
175
+ this.lastSynchronizedTime = null;
176
+ this.lastRenderedIndex = -1;
177
+ this.lastRenderedTime = -1;
178
+ this.renderPausedFrame();
179
+ }
135
180
  /** Get cache-related diagnostics for the active renderer session. */
136
181
  getCacheStats() {
137
182
  const state = this.getWorkerRendererState();
@@ -188,9 +233,16 @@ class BaseVideoSubtitleRenderer {
188
233
  minRenderTime: Math.round(minRenderTime * 100) / 100,
189
234
  lastRenderTime: Math.round(this.perfStats.lastRenderTime * 100) / 100,
190
235
  renderFps: this.perfStats.fpsTimestamps.length,
191
- currentIndex: this.lastRenderedIndex
236
+ currentIndex: this.lastRenderedIndex,
237
+ syncMode: this.getSynchronizationMode()
192
238
  };
193
239
  }
240
+ /** Get the active video synchronization clock. */
241
+ getSynchronizationMode() {
242
+ if (this.frameScheduler)
243
+ return this.frameScheduler.mode;
244
+ return supportsFrameAwareSync(this.video, this.frameAwareSync) ? 'video-frame' : 'animation-frame';
245
+ }
194
246
  /** Set display settings and force re-render */
195
247
  setDisplaySettings(settings) {
196
248
  const nextSettings = {
@@ -210,19 +262,24 @@ class BaseVideoSubtitleRenderer {
210
262
  this.displaySettings = nextSettings;
211
263
  // Force re-render if settings changed
212
264
  if (changed) {
265
+ this.invalidatePresentation();
213
266
  this.lastRenderedIndex = -1;
214
267
  this.lastRenderedTime = -1;
268
+ this.renderPausedFrame();
215
269
  }
216
270
  }
217
271
  /** Reset display settings to defaults */
218
272
  resetDisplaySettings() {
219
273
  this.displaySettings = { ...DEFAULT_DISPLAY_SETTINGS };
274
+ this.invalidatePresentation();
220
275
  this.lastRenderedIndex = -1;
221
276
  this.lastRenderedTime = -1;
277
+ this.renderPausedFrame();
222
278
  }
223
279
  /** Start initialization. */
224
280
  startInit() {
225
- this.init().catch((error) => {
281
+ this.initPromise = this.init();
282
+ this.initPromise.catch((error) => {
226
283
  this.emitEvent({
227
284
  type: 'error',
228
285
  format: this.format,
@@ -230,51 +287,275 @@ class BaseVideoSubtitleRenderer {
230
287
  });
231
288
  });
232
289
  }
290
+ /** Wait for canvas, parser, and worker initialization before accepting pushed data. */
291
+ async waitUntilInitialized() {
292
+ await this.initPromise;
293
+ if (this.disposed) {
294
+ throw new Error(`${this.format.toUpperCase()} renderer has been disposed`);
295
+ }
296
+ }
297
+ /** Re-run cue selection after a live stream mutation. */
298
+ refreshStreamPresentation() {
299
+ this.invalidatePresentation();
300
+ this.lastRenderedIndex = -1;
301
+ this.lastRenderedTime = -1;
302
+ this.renderPausedFrame();
303
+ }
304
+ /** Clear the currently presented cue after a live stream reset. */
305
+ clearStreamPresentation() {
306
+ this.invalidatePresentation();
307
+ this.lastSynchronizedTime = null;
308
+ this.lastRenderedIndex = -1;
309
+ this.lastRenderedTime = -1;
310
+ this.lastRenderedData = null;
311
+ this.lastCueIndex = null;
312
+ this.currentCueMetadata = null;
313
+ this.lastRenderInfo = null;
314
+ this.offscreenFrameMetadata.clear();
315
+ const state = this.getWorkerRendererState();
316
+ if (this.useWorkerOffscreen && state.workerReady && state.sessionId) {
317
+ sendToWorker({ type: 'clearOffscreenCanvas', sessionId: state.sessionId }).catch(() => { });
318
+ }
319
+ else if (this.useWebGPU && this.webgpuRenderer) {
320
+ this.webgpuRenderer.clear();
321
+ }
322
+ else if (this.useWebGL2 && this.webgl2Renderer) {
323
+ this.webgl2Renderer.clear();
324
+ }
325
+ else if (this.ctx && this.canvas) {
326
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
327
+ }
328
+ this.emitEvent({ type: 'cue-change', cue: null });
329
+ }
233
330
  /** Initialize the renderer. */
234
331
  async init() {
235
332
  await initWasm();
236
333
  this.createCanvas();
237
334
  await new Promise((resolve) => setTimeout(resolve, 0));
238
335
  await this.loadSubtitles();
336
+ await this.ensureWorkerOffscreenAttached();
239
337
  this.startRenderLoop();
240
338
  }
241
- /** Create the canvas overlay positioned over the video. */
242
- createCanvas() {
243
- this.canvas = document.createElement('canvas');
244
- Object.assign(this.canvas.style, {
245
- position: 'absolute',
246
- pointerEvents: 'none',
247
- zIndex: '10'
248
- });
249
- const parent = this.video.parentElement;
250
- if (parent) {
251
- if (window.getComputedStyle(parent).position === 'static') {
252
- parent.style.position = 'relative';
253
- }
254
- parent.appendChild(this.canvas);
339
+ shouldPreferWorkerOffscreen() {
340
+ return !this.providedCanvas && this.offscreenRenderOption && canUseWorkerOffscreenRender();
341
+ }
342
+ isWorkerOffscreenPresent() {
343
+ return this.useWorkerOffscreen;
344
+ }
345
+ getDefaultContainer() {
346
+ if (this.video.parentElement)
347
+ return this.video.parentElement;
348
+ const parent = this.video.parentNode;
349
+ if (parent && 'appendChild' in parent && 'host' in parent) {
350
+ return parent;
351
+ }
352
+ return null;
353
+ }
354
+ getContainerElement(container) {
355
+ return 'host' in container ? container.host : container;
356
+ }
357
+ getCanvasParent(canvas) {
358
+ return canvas.parentNode ?? canvas.parentElement;
359
+ }
360
+ removeOwnedCanvas() {
361
+ if (!this.ownsCanvas || !this.canvas)
362
+ return;
363
+ this.getCanvasParent(this.canvas)?.removeChild(this.canvas);
364
+ }
365
+ mountOverlayCanvas(canvas) {
366
+ if (!canvas.style.position)
367
+ canvas.style.position = 'absolute';
368
+ if (!canvas.style.pointerEvents)
369
+ canvas.style.pointerEvents = 'none';
370
+ if (!canvas.style.zIndex)
371
+ canvas.style.zIndex = '10';
372
+ const existingContainer = this.getCanvasParent(canvas);
373
+ const container = this.requestedContainer ??
374
+ (existingContainer &&
375
+ 'appendChild' in existingContainer &&
376
+ ('style' in existingContainer || 'host' in existingContainer)
377
+ ? existingContainer
378
+ : this.getDefaultContainer());
379
+ this.overlayContainer = container;
380
+ if (!container)
381
+ return;
382
+ const containerElement = this.getContainerElement(container);
383
+ if (window.getComputedStyle(containerElement).position === 'static') {
384
+ containerElement.style.position = 'relative';
385
+ }
386
+ if (this.getCanvasParent(canvas) !== container) {
387
+ container.appendChild(canvas);
388
+ }
389
+ }
390
+ selectBackend() {
391
+ if (this.backendOption === 'webgpu') {
392
+ void this.initWebGPU(false);
393
+ return;
394
+ }
395
+ if (this.backendOption === 'webgl2') {
396
+ void this.initWebGL2(false);
397
+ return;
398
+ }
399
+ if (this.backendOption === 'worker-offscreen') {
400
+ this.pendingWorkerOffscreen = true;
401
+ return;
402
+ }
403
+ if (this.backendOption === 'canvas2d') {
404
+ this.initCanvas2D();
405
+ return;
255
406
  }
256
- // Try WebGPU first, then WebGL2, then Canvas2D
257
407
  if (isWebGPUSupported()) {
258
- this.initWebGPU();
408
+ void this.initWebGPU();
259
409
  }
260
410
  else if (isWebGL2Supported()) {
261
- this.initWebGL2();
411
+ void this.initWebGL2();
412
+ }
413
+ else if (this.shouldPreferWorkerOffscreen()) {
414
+ this.pendingWorkerOffscreen = true;
262
415
  }
263
416
  else {
264
417
  this.initCanvas2D();
265
418
  }
419
+ }
420
+ /** Create the canvas overlay positioned over the video. */
421
+ createCanvas() {
422
+ this.canvas = this.providedCanvas ?? document.createElement('canvas');
423
+ this.ownsCanvas = !this.providedCanvas;
424
+ this.mountOverlayCanvas(this.canvas);
425
+ this.selectBackend();
266
426
  this.updateCanvasSize();
267
427
  this.resizeObserver = new ResizeObserver(() => this.updateCanvasSize());
268
428
  this.resizeObserver.observe(this.video);
269
429
  this.loadedMetadataHandler = () => this.updateCanvasSize();
270
430
  this.seekedHandler = () => {
431
+ this.invalidatePresentation();
271
432
  this.lastRenderedIndex = -1;
272
433
  this.lastRenderedTime = -1;
434
+ this.lastSynchronizedTime = null;
435
+ this.offscreenFrameMetadata.clear();
273
436
  this.onSeek();
437
+ this.renderPausedFrame();
274
438
  };
275
439
  this.video.addEventListener('loadedmetadata', this.loadedMetadataHandler);
276
440
  this.video.addEventListener('seeked', this.seekedHandler);
277
441
  }
442
+ preferWorkerOffscreenOrCanvas2D() {
443
+ if (this.backendOption === 'auto' && this.shouldPreferWorkerOffscreen()) {
444
+ this.pendingWorkerOffscreen = true;
445
+ const state = this.getWorkerRendererState();
446
+ if (state.useWorker && state.workerReady && state.sessionId) {
447
+ void this.ensureWorkerOffscreenAttached();
448
+ }
449
+ return;
450
+ }
451
+ this.initCanvas2D();
452
+ }
453
+ ensureWorkerOffscreenAttached() {
454
+ if (this.disposed)
455
+ return Promise.resolve();
456
+ if (this.offscreenAttachPromise)
457
+ return this.offscreenAttachPromise;
458
+ if (!this.pendingWorkerOffscreen || !this.canvas)
459
+ return Promise.resolve();
460
+ const state = this.getWorkerRendererState();
461
+ if (!state.useWorker || !state.workerReady || !state.sessionId) {
462
+ this.pendingWorkerOffscreen = false;
463
+ this.initCanvas2D();
464
+ this.updateCanvasSize();
465
+ return Promise.resolve();
466
+ }
467
+ const sessionId = state.sessionId;
468
+ this.pendingWorkerOffscreen = false;
469
+ this.offscreenTransferPending = true;
470
+ let attachPromise;
471
+ attachPromise = this.attachWorkerOffscreen(sessionId).finally(() => {
472
+ this.offscreenTransferPending = false;
473
+ if (this.offscreenAttachPromise === attachPromise) {
474
+ this.offscreenAttachPromise = null;
475
+ }
476
+ });
477
+ this.offscreenAttachPromise = attachPromise;
478
+ return attachPromise;
479
+ }
480
+ async attachWorkerOffscreen(sessionId) {
481
+ try {
482
+ const transferableCanvas = this.canvas;
483
+ const offscreen = transferableCanvas.transferControlToOffscreen();
484
+ const attachResponse = await sendToWorker({
485
+ type: 'attachOffscreenCanvas',
486
+ sessionId,
487
+ canvas: offscreen
488
+ });
489
+ if (attachResponse.type === 'error') {
490
+ throw new Error(attachResponse.message);
491
+ }
492
+ if (attachResponse.type !== 'offscreenAttached') {
493
+ throw new Error('Worker OffscreenCanvas attach failed');
494
+ }
495
+ if (this.disposed) {
496
+ await sendToWorker({ type: 'detachOffscreenCanvas', sessionId });
497
+ return;
498
+ }
499
+ this.useWorkerOffscreen = true;
500
+ this.emitRendererBackend('worker-offscreen');
501
+ this.lastRenderedIndex = -1;
502
+ this.lastRenderedTime = -1;
503
+ await sendToWorker({
504
+ type: 'resizeOffscreenCanvas',
505
+ sessionId,
506
+ width: this.canvasPixelWidth,
507
+ height: this.canvasPixelHeight
508
+ });
509
+ }
510
+ catch (error) {
511
+ this.offscreenTransferPending = false;
512
+ this.useWorkerOffscreen = false;
513
+ if (this.disposed)
514
+ return;
515
+ this.recreateCanvasForMainThreadFallback();
516
+ this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'Worker OffscreenCanvas present unavailable; falling back to main-thread Canvas2D.', {
517
+ format: this.format,
518
+ details: { reason: error instanceof Error ? error.message : String(error) }
519
+ }));
520
+ }
521
+ }
522
+ recreateCanvasForMainThreadFallback() {
523
+ this.removeOwnedCanvas();
524
+ this.canvas = document.createElement('canvas');
525
+ this.ownsCanvas = true;
526
+ this.mountOverlayCanvas(this.canvas);
527
+ this.initCanvas2D();
528
+ this.updateCanvasSize();
529
+ if (!this.tempCanvas) {
530
+ this.tempCanvas = document.createElement('canvas');
531
+ this.tempCtx = this.tempCanvas.getContext('2d');
532
+ }
533
+ }
534
+ fallbackFromWorkerOffscreen(reason) {
535
+ if (!this.useWorkerOffscreen || this.disposed)
536
+ return;
537
+ const state = this.getWorkerRendererState();
538
+ this.invalidatePresentation();
539
+ this.useWorkerOffscreen = false;
540
+ this.pendingWorkerOffscreen = false;
541
+ this.offscreenFrameMetadata.clear();
542
+ if (state.sessionId) {
543
+ sendToWorker({ type: 'detachOffscreenCanvas', sessionId: state.sessionId }).catch(() => { });
544
+ }
545
+ this.recreateCanvasForMainThreadFallback();
546
+ this.lastRenderedIndex = -2;
547
+ this.lastRenderedTime = -1;
548
+ this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'Worker OffscreenCanvas present failed; falling back to main-thread Canvas2D.', {
549
+ format: this.format,
550
+ details: { reason }
551
+ }));
552
+ }
553
+ getOffscreenFrameMetadata(index) {
554
+ return this.offscreenFrameMetadata.get(index) ?? null;
555
+ }
556
+ clearOffscreenFrameMetadata() {
557
+ this.offscreenFrameMetadata.clear();
558
+ }
278
559
  emitEvent(event) {
279
560
  this.onEvent?.(event);
280
561
  }
@@ -318,15 +599,16 @@ class BaseVideoSubtitleRenderer {
318
599
  this.lastRenderInfo = info;
319
600
  }
320
601
  /** Initialize WebGPU renderer. */
321
- async initWebGPU() {
602
+ async initWebGPU(allowBackendFallback = true) {
322
603
  try {
323
604
  this.webgpuRenderer = new WebGPURenderer();
324
605
  await this.webgpuRenderer.init();
325
606
  if (!this.canvas)
326
607
  return;
327
608
  const bounds = this.getVideoContentBounds();
328
- const width = Math.max(1, bounds.width * window.devicePixelRatio);
329
- const height = Math.max(1, bounds.height * window.devicePixelRatio);
609
+ const pixelRatio = this.getDevicePixelRatio();
610
+ const width = Math.max(1, bounds.width * pixelRatio);
611
+ const height = Math.max(1, bounds.height * pixelRatio);
330
612
  await this.webgpuRenderer.setCanvas(this.canvas, width, height);
331
613
  this.useWebGPU = true;
332
614
  this.emitRendererBackend('webgpu');
@@ -336,25 +618,25 @@ class BaseVideoSubtitleRenderer {
336
618
  this.webgpuRenderer = null;
337
619
  this.useWebGPU = false;
338
620
  this.onWebGPUFallback?.();
339
- // Try WebGL2 before Canvas2D
340
- if (isWebGL2Supported()) {
341
- this.initWebGL2();
621
+ if (allowBackendFallback && isWebGL2Supported()) {
622
+ void this.initWebGL2();
342
623
  }
343
624
  else {
344
- this.initCanvas2D();
625
+ this.preferWorkerOffscreenOrCanvas2D();
345
626
  }
346
627
  }
347
628
  }
348
629
  /** Initialize WebGL2 renderer. */
349
- async initWebGL2() {
630
+ async initWebGL2(allowBackendFallback = true) {
350
631
  try {
351
632
  this.webgl2Renderer = new WebGL2Renderer();
352
633
  await this.webgl2Renderer.init();
353
634
  if (!this.canvas)
354
635
  return;
355
636
  const bounds = this.getVideoContentBounds();
356
- const width = Math.max(1, bounds.width * window.devicePixelRatio);
357
- const height = Math.max(1, bounds.height * window.devicePixelRatio);
637
+ const pixelRatio = this.getDevicePixelRatio();
638
+ const width = Math.max(1, bounds.width * pixelRatio);
639
+ const height = Math.max(1, bounds.height * pixelRatio);
358
640
  await this.webgl2Renderer.setCanvas(this.canvas, width, height);
359
641
  this.useWebGL2 = true;
360
642
  this.emitRendererBackend('webgl2');
@@ -364,7 +646,12 @@ class BaseVideoSubtitleRenderer {
364
646
  this.webgl2Renderer = null;
365
647
  this.useWebGL2 = false;
366
648
  this.onWebGL2Fallback?.();
367
- this.initCanvas2D();
649
+ if (allowBackendFallback) {
650
+ this.preferWorkerOffscreenOrCanvas2D();
651
+ }
652
+ else {
653
+ this.initCanvas2D();
654
+ }
368
655
  }
369
656
  }
370
657
  /** Initialize Canvas2D renderer. */
@@ -378,6 +665,23 @@ class BaseVideoSubtitleRenderer {
378
665
  }
379
666
  /** Called when video seeks. */
380
667
  onSeek() { }
668
+ getDevicePixelRatio() {
669
+ const devicePixelRatio = Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1;
670
+ return this.devicePixelRatioCap === null ? devicePixelRatio : Math.min(devicePixelRatio, this.devicePixelRatioCap);
671
+ }
672
+ getCanvasMountOffset() {
673
+ if (!this.overlayContainer)
674
+ return { x: 0, y: 0 };
675
+ const containerElement = this.getContainerElement(this.overlayContainer);
676
+ if (typeof containerElement.getBoundingClientRect !== 'function')
677
+ return { x: 0, y: 0 };
678
+ const videoRect = this.video.getBoundingClientRect();
679
+ const containerRect = containerElement.getBoundingClientRect();
680
+ return {
681
+ x: (videoRect.left ?? videoRect.x) - (containerRect.left ?? containerRect.x),
682
+ y: (videoRect.top ?? videoRect.y) - (containerRect.top ?? containerRect.y)
683
+ };
684
+ }
381
685
  /** Calculate the actual video content bounds, accounting for letterboxing/pillarboxing */
382
686
  getVideoContentBounds() {
383
687
  const rect = this.video.getBoundingClientRect();
@@ -420,96 +724,181 @@ class BaseVideoSubtitleRenderer {
420
724
  const bounds = this.getVideoContentBounds();
421
725
  const width = bounds.width > 0 ? bounds.width : this.video.videoWidth || 1920;
422
726
  const height = bounds.height > 0 ? bounds.height : this.video.videoHeight || 1080;
423
- const pixelWidth = Math.max(1, width * window.devicePixelRatio);
424
- const pixelHeight = Math.max(1, height * window.devicePixelRatio);
425
- this.canvas.width = pixelWidth;
426
- this.canvas.height = pixelHeight;
727
+ const pixelRatio = this.getDevicePixelRatio();
728
+ const pixelWidth = Math.max(1, width * pixelRatio);
729
+ const pixelHeight = Math.max(1, height * pixelRatio);
730
+ this.canvasPixelWidth = pixelWidth;
731
+ this.canvasPixelHeight = pixelHeight;
427
732
  // Position canvas to match video content area
428
- this.canvas.style.left = `${bounds.x}px`;
429
- this.canvas.style.top = `${bounds.y}px`;
733
+ const mountOffset = this.getCanvasMountOffset();
734
+ this.canvas.style.left = `${mountOffset.x + bounds.x}px`;
735
+ this.canvas.style.top = `${mountOffset.y + bounds.y}px`;
430
736
  this.canvas.style.width = `${bounds.width}px`;
431
737
  this.canvas.style.height = `${bounds.height}px`;
432
- // Update GPU renderer size if active
433
- if (this.useWebGPU && this.webgpuRenderer) {
434
- this.webgpuRenderer.updateSize(pixelWidth, pixelHeight);
738
+ if (this.useWorkerOffscreen || this.pendingWorkerOffscreen || this.offscreenTransferPending) {
739
+ const state = this.getWorkerRendererState();
740
+ if (this.useWorkerOffscreen && state.sessionId && state.workerReady) {
741
+ sendToWorker({
742
+ type: 'resizeOffscreenCanvas',
743
+ sessionId: state.sessionId,
744
+ width: pixelWidth,
745
+ height: pixelHeight
746
+ }).catch(() => { });
747
+ }
435
748
  }
436
- else if (this.useWebGL2 && this.webgl2Renderer) {
437
- this.webgl2Renderer.updateSize(pixelWidth, pixelHeight);
749
+ else {
750
+ this.canvas.width = pixelWidth;
751
+ this.canvas.height = pixelHeight;
752
+ // Update GPU renderer size if active
753
+ if (this.useWebGPU && this.webgpuRenderer) {
754
+ this.webgpuRenderer.updateSize(pixelWidth, pixelHeight);
755
+ }
756
+ else if (this.useWebGL2 && this.webgl2Renderer) {
757
+ this.webgl2Renderer.updateSize(pixelWidth, pixelHeight);
758
+ }
438
759
  }
439
760
  this.lastRenderedIndex = -1;
440
761
  this.lastRenderedTime = -1;
762
+ this.invalidatePresentation();
763
+ if (this.frameScheduler) {
764
+ this.renderPausedFrame();
765
+ }
441
766
  }
442
767
  /** Start the render loop. */
443
768
  startRenderLoop() {
444
- // Create reusable temp canvas for rendering
445
- this.tempCanvas = document.createElement('canvas');
446
- this.tempCtx = this.tempCanvas.getContext('2d');
447
- const render = () => {
448
- if (this.disposed)
449
- return;
450
- if (this.isLoaded) {
451
- const currentTime = this.video.currentTime + this.timeOffset;
452
- const currentIndex = this.findCurrentIndex(currentTime);
453
- // Only re-render if index changed
454
- if (currentIndex !== this.lastRenderedIndex) {
455
- const cacheHit = currentIndex >= 0 && this.getWorkerRendererState().frameCache.has(currentIndex);
456
- const startTime = performance.now();
457
- const outcome = this.renderFrame(currentTime, currentIndex);
458
- const endTime = performance.now();
459
- // Track performance
460
- const renderTime = endTime - startTime;
461
- this.perfStats.lastRenderTime = renderTime;
462
- this.perfStats.renderTimes.push(renderTime);
463
- // Keep only last 60 samples for rolling average
464
- if (this.perfStats.renderTimes.length > 60) {
465
- this.perfStats.renderTimes.shift();
466
- }
467
- this.perfStats.framesRendered++;
468
- this.perfStats.fpsTimestamps.push(endTime);
469
- // Check for frame drop (if render took longer than frame budget ~16.67ms for 60fps)
470
- const frameBudget = 16.67;
471
- if (renderTime > frameBudget) {
472
- this.perfStats.framesDropped++;
473
- }
474
- if (outcome.warning) {
475
- this.emitWarning(outcome.warning);
476
- }
477
- this.lastRenderedIndex = currentIndex;
478
- this.lastRenderedTime = currentTime;
479
- const cue = currentIndex >= 0 ? this.buildCueMetadata(currentIndex) : null;
480
- this.emitCueChange(cue);
481
- this.recordLastRenderInfo({
482
- time: currentTime,
483
- index: currentIndex,
484
- status: outcome.status,
485
- backend: this.currentRendererBackend,
486
- usingWorker: this.getCacheStats().usingWorker,
487
- cacheHit,
488
- renderDuration: Math.round(renderTime * 100) / 100,
489
- frameWidth: outcome.data?.width ?? null,
490
- frameHeight: outcome.data?.height ?? null,
491
- compositionCount: outcome.data?.compositionData.length ?? 0,
492
- cue,
493
- cache: this.getCacheStats(),
494
- capturedAt: endTime
495
- });
496
- this.emitEvent({ type: 'stats', stats: this.getStats() });
497
- if (currentIndex >= 0 && (this.prefetchBefore > 0 || this.prefetchAfter > 0)) {
498
- const prefetch = this
499
- .prefetchAroundTime;
500
- prefetch?.call(this, currentTime).catch(() => { });
501
- }
502
- }
769
+ if (!this.useWorkerOffscreen) {
770
+ this.tempCanvas = document.createElement('canvas');
771
+ this.tempCtx = this.tempCanvas.getContext('2d');
772
+ }
773
+ this.frameScheduler = new VideoFrameScheduler(this.video, this.frameAwareSync, (tick) => this.renderSynchronizedFrame(tick));
774
+ this.renderPausedFrame();
775
+ this.frameScheduler.start();
776
+ }
777
+ renderPausedFrame() {
778
+ if (!this.video.paused && !this.video.ended)
779
+ return;
780
+ this.renderSynchronizedFrame({ mediaTime: this.video.currentTime, presentedFrames: null });
781
+ }
782
+ renderSynchronizedFrame(tick) {
783
+ if (this.disposed || !this.isLoaded)
784
+ return;
785
+ const currentTime = tick.mediaTime + this.timeOffset;
786
+ this.lastSynchronizedTime = currentTime;
787
+ if (tick.presentedFrames !== null) {
788
+ if (this.lastPresentedFrames !== null && tick.presentedFrames > this.lastPresentedFrames + 1) {
789
+ this.perfStats.framesDropped += tick.presentedFrames - this.lastPresentedFrames - 1;
503
790
  }
504
- this.animationFrameId = requestAnimationFrame(render);
505
- };
506
- this.animationFrameId = requestAnimationFrame(render);
791
+ this.lastPresentedFrames = tick.presentedFrames;
792
+ }
793
+ const currentIndex = this.findCurrentIndex(currentTime);
794
+ if (currentIndex === this.lastRenderedIndex)
795
+ return;
796
+ const cacheHit = !this.useWorkerOffscreen && currentIndex >= 0 && this.getWorkerRendererState().frameCache.has(currentIndex);
797
+ const workerOffscreenPresent = this.useWorkerOffscreen;
798
+ const startTime = performance.now();
799
+ this.presentationToken++;
800
+ const outcome = this.renderFrame(currentTime, currentIndex);
801
+ const endTime = performance.now();
802
+ this.lastRenderedIndex = currentIndex;
803
+ this.lastRenderedTime = currentTime;
804
+ // Worker OffscreenCanvas presentation completes asynchronously. Its
805
+ // callback records the real status, metadata, and render duration.
806
+ if (workerOffscreenPresent)
807
+ return;
808
+ const renderTime = endTime - startTime;
809
+ this.recordRenderPerformance(renderTime, endTime);
810
+ if (outcome.warning) {
811
+ this.emitWarning(outcome.warning);
812
+ }
813
+ const cue = currentIndex >= 0 ? this.buildCueMetadata(currentIndex) : null;
814
+ this.emitCueChange(cue);
815
+ this.recordLastRenderInfo({
816
+ time: currentTime,
817
+ index: currentIndex,
818
+ status: outcome.status,
819
+ backend: this.currentRendererBackend,
820
+ usingWorker: this.getCacheStats().usingWorker,
821
+ cacheHit,
822
+ renderDuration: Math.round(renderTime * 100) / 100,
823
+ frameWidth: outcome.data?.width ?? null,
824
+ frameHeight: outcome.data?.height ?? null,
825
+ compositionCount: outcome.data?.compositionData.length ?? 0,
826
+ cue,
827
+ cache: this.getCacheStats(),
828
+ capturedAt: endTime
829
+ });
830
+ this.emitEvent({ type: 'stats', stats: this.getStats() });
831
+ if (currentIndex >= 0 && (this.prefetchBefore > 0 || this.prefetchAfter > 0)) {
832
+ const prefetch = this.prefetchAroundTime;
833
+ prefetch?.call(this, currentTime).catch(() => { });
834
+ }
835
+ }
836
+ getCurrentSynchronizationTime() {
837
+ return this.lastSynchronizedTime ?? this.video.currentTime + this.timeOffset;
838
+ }
839
+ getPresentationToken() {
840
+ return this.presentationToken;
841
+ }
842
+ isCurrentPresentation(token) {
843
+ return !this.disposed && token === this.presentationToken;
844
+ }
845
+ invalidatePresentation() {
846
+ this.presentationToken++;
847
+ }
848
+ watchPendingRender(index, pending) {
849
+ const token = this.getPresentationToken();
850
+ void pending.then(() => {
851
+ if (!this.isCurrentPresentation(token))
852
+ return;
853
+ if (this.findCurrentIndex(this.getCurrentSynchronizationTime()) !== index)
854
+ return;
855
+ this.lastRenderedIndex = -1;
856
+ this.renderPausedFrame();
857
+ }, () => { });
858
+ }
859
+ recordRenderPerformance(renderTime, capturedAt, countAsDropped = true) {
860
+ this.perfStats.lastRenderTime = renderTime;
861
+ this.perfStats.renderTimes.push(renderTime);
862
+ if (this.perfStats.renderTimes.length > 60) {
863
+ this.perfStats.renderTimes.shift();
864
+ }
865
+ this.perfStats.framesRendered++;
866
+ this.perfStats.fpsTimestamps.push(capturedAt);
867
+ if (countAsDropped && renderTime > 16.67) {
868
+ this.perfStats.framesDropped++;
869
+ }
870
+ }
871
+ recordOffscreenRenderCompletion(time, index, status, width, height, compositionCount, startedAt) {
872
+ const completedAt = performance.now();
873
+ const renderTime = completedAt - startedAt;
874
+ this.recordRenderPerformance(renderTime, completedAt, false);
875
+ const cue = index >= 0 ? this.buildCueMetadata(index) : null;
876
+ this.emitCueChange(cue);
877
+ this.recordLastRenderInfo({
878
+ time,
879
+ index,
880
+ status,
881
+ backend: this.currentRendererBackend,
882
+ usingWorker: this.getCacheStats().usingWorker,
883
+ cacheHit: false,
884
+ renderDuration: Math.round(renderTime * 100) / 100,
885
+ frameWidth: width,
886
+ frameHeight: height,
887
+ compositionCount,
888
+ cue,
889
+ cache: this.getCacheStats(),
890
+ capturedAt: completedAt
891
+ });
892
+ this.emitEvent({ type: 'stats', stats: this.getStats() });
507
893
  }
508
894
  /** Render a subtitle frame to the canvas. */
509
895
  renderFrame(time, index) {
510
896
  if (!this.canvas) {
511
897
  return { status: 'failed', data: null, warning: null };
512
898
  }
899
+ if (this.useWorkerOffscreen) {
900
+ return this.renderFrameWorkerOffscreen(time, index);
901
+ }
513
902
  // Get the data for this index
514
903
  const data = index >= 0 ? this.renderAtIndex(index) : undefined;
515
904
  // If data is undefined, it means async loading is in progress
@@ -546,6 +935,83 @@ class BaseVideoSubtitleRenderer {
546
935
  }
547
936
  return { status: 'rendered', data, warning: null };
548
937
  }
938
+ renderFrameWorkerOffscreen(time, index) {
939
+ const state = this.getWorkerRendererState();
940
+ if (!state.sessionId || !state.workerReady) {
941
+ queueMicrotask(() => this.fallbackFromWorkerOffscreen('Worker session is not ready.'));
942
+ return { status: 'pending', data: null, warning: null };
943
+ }
944
+ const token = this.getPresentationToken();
945
+ const sessionId = state.sessionId;
946
+ const startedAt = performance.now();
947
+ void sendToWorker({
948
+ type: 'presentOffscreen',
949
+ sessionId,
950
+ format: this.format,
951
+ index,
952
+ canvasWidth: this.canvasPixelWidth,
953
+ canvasHeight: this.canvasPixelHeight,
954
+ displaySettings: { ...this.displaySettings }
955
+ })
956
+ .then((response) => {
957
+ if (!this.isCurrentPresentation(token))
958
+ return;
959
+ if (response.type === 'error') {
960
+ this.recordOffscreenRenderCompletion(time, index, 'failed', null, null, 0, startedAt);
961
+ this.fallbackFromWorkerOffscreen(response.message);
962
+ return;
963
+ }
964
+ if (response.type !== 'offscreenPresented') {
965
+ this.recordOffscreenRenderCompletion(time, index, 'failed', null, null, 0, startedAt);
966
+ this.fallbackFromWorkerOffscreen(`Unexpected worker response: ${response.type}`);
967
+ return;
968
+ }
969
+ if (response.status === 'failed') {
970
+ const warning = warningFromRenderIssue(response.renderIssue?.trim() || null, {
971
+ format: this.format,
972
+ cueIndex: index
973
+ });
974
+ if (warning) {
975
+ this.emitWarning(warning);
976
+ }
977
+ this.recordOffscreenRenderCompletion(time, index, 'failed', response.width ?? null, response.height ?? null, response.compositionCount ?? 0, startedAt);
978
+ if (response.fatal) {
979
+ this.fallbackFromWorkerOffscreen(response.renderIssue || 'Offscreen presentation failed.');
980
+ }
981
+ return;
982
+ }
983
+ if (response.status === 'cleared' || response.status === 'empty' || response.compositionCount === 0) {
984
+ this.lastRenderedData = null;
985
+ }
986
+ if (index >= 0) {
987
+ this.offscreenFrameMetadata.set(index, {
988
+ width: response.width ?? null,
989
+ height: response.height ?? null,
990
+ bounds: response.bounds ?? null,
991
+ compositionCount: response.compositionCount ?? 0
992
+ });
993
+ }
994
+ const warning = warningFromRenderIssue(response.renderIssue?.trim() || null, {
995
+ format: this.format,
996
+ cueIndex: index
997
+ });
998
+ if (warning) {
999
+ this.emitWarning(warning);
1000
+ }
1001
+ this.recordOffscreenRenderCompletion(time, index, response.status, response.width ?? null, response.height ?? null, response.compositionCount ?? 0, startedAt);
1002
+ })
1003
+ .catch((error) => {
1004
+ if (!this.isCurrentPresentation(token))
1005
+ return;
1006
+ this.recordOffscreenRenderCompletion(time, index, 'failed', null, null, 0, startedAt);
1007
+ this.fallbackFromWorkerOffscreen(error instanceof Error ? error.message : String(error));
1008
+ });
1009
+ if (index < 0) {
1010
+ this.lastRenderedData = null;
1011
+ return { status: 'cleared', data: null, warning: null };
1012
+ }
1013
+ return { status: 'pending', data: null, warning: null };
1014
+ }
549
1015
  computeLayout(data) {
550
1016
  if (!this.canvas) {
551
1017
  return { scaleX: 1, scaleY: 1, shiftX: 0, shiftY: 0, opacity: this.displaySettings.opacity };
@@ -675,10 +1141,9 @@ class BaseVideoSubtitleRenderer {
675
1141
  /** Dispose of all resources. */
676
1142
  dispose() {
677
1143
  this.disposed = true;
678
- if (this.animationFrameId !== null) {
679
- cancelAnimationFrame(this.animationFrameId);
680
- this.animationFrameId = null;
681
- }
1144
+ this.invalidatePresentation();
1145
+ this.frameScheduler?.stop();
1146
+ this.frameScheduler = null;
682
1147
  this.resizeObserver?.disconnect();
683
1148
  this.resizeObserver = null;
684
1149
  if (this.loadedMetadataHandler) {
@@ -698,7 +1163,11 @@ class BaseVideoSubtitleRenderer {
698
1163
  this.webgl2Renderer.destroy();
699
1164
  this.webgl2Renderer = null;
700
1165
  }
701
- this.canvas?.parentElement?.removeChild(this.canvas);
1166
+ const state = this.getWorkerRendererState();
1167
+ if (this.useWorkerOffscreen && state.sessionId) {
1168
+ sendToWorker({ type: 'detachOffscreenCanvas', sessionId: state.sessionId }).catch(() => { });
1169
+ }
1170
+ this.removeOwnedCanvas();
702
1171
  this.canvas = null;
703
1172
  this.ctx = null;
704
1173
  this.tempCanvas = null;
@@ -707,8 +1176,13 @@ class BaseVideoSubtitleRenderer {
707
1176
  this.currentCueMetadata = null;
708
1177
  this.parserMetadata = null;
709
1178
  this.lastRenderInfo = null;
1179
+ this.offscreenFrameMetadata.clear();
710
1180
  this.useWebGPU = false;
711
1181
  this.useWebGL2 = false;
1182
+ this.useWorkerOffscreen = false;
1183
+ this.pendingWorkerOffscreen = false;
1184
+ this.offscreenTransferPending = false;
1185
+ this.offscreenAttachPromise = null;
712
1186
  }
713
1187
  }
714
1188
  /**
@@ -718,6 +1192,8 @@ class BaseVideoSubtitleRenderer {
718
1192
  export class PgsRenderer extends BaseVideoSubtitleRenderer {
719
1193
  pgsParser = null;
720
1194
  state = createWorkerState();
1195
+ streamOperationQueue = Promise.resolve();
1196
+ streamGeneration = 0;
721
1197
  onLoading;
722
1198
  onLoaded;
723
1199
  onError;
@@ -741,7 +1217,9 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
741
1217
  return;
742
1218
  }
743
1219
  if (!this.subUrl) {
744
- throw new Error('No subtitle content or URL provided');
1220
+ await this.beginPgsPushSession();
1221
+ this.onLoaded?.();
1222
+ return;
745
1223
  }
746
1224
  if (!this.streamingLoad) {
747
1225
  const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(this.subUrl, {
@@ -775,6 +1253,150 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
775
1253
  }
776
1254
  }
777
1255
  }
1256
+ async beginPgsPushSession() {
1257
+ if (this.state.useWorker) {
1258
+ try {
1259
+ this.state.sessionId = createWorkerSessionId();
1260
+ await getOrCreateWorker();
1261
+ this.emitWorkerState(true, false, this.state.sessionId);
1262
+ const response = await sendToWorker({ type: 'beginPgs', sessionId: this.state.sessionId });
1263
+ if (response.type === 'error')
1264
+ throw new Error(response.message);
1265
+ if (response.type !== 'pgsProgress')
1266
+ throw new Error('Unexpected PGS worker response');
1267
+ this.applyPgsIndexState(response.metadata, response.timestamps, true, true);
1268
+ this.state.workerReady = true;
1269
+ this.isLoaded = true;
1270
+ this.emitWorkerState(true, true, this.state.sessionId);
1271
+ return;
1272
+ }
1273
+ catch (workerError) {
1274
+ this.state.useWorker = false;
1275
+ this.state.workerReady = false;
1276
+ this.state.sessionId = null;
1277
+ this.emitWorkerState(false, false, null, true);
1278
+ this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'PGS worker initialization failed, falling back to main-thread rendering.', {
1279
+ format: 'pgs',
1280
+ details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
1281
+ }));
1282
+ }
1283
+ }
1284
+ await this.yieldToMain();
1285
+ this.pgsParser = new PgsParser({ debug: this.debug, onWarning: (warning) => this.emitWarning(warning) });
1286
+ this.pgsParser.reset();
1287
+ const metadata = this.pgsParser.getMetadata();
1288
+ this.applyPgsIndexState(metadata, this.pgsParser.getTimestamps(), true, false);
1289
+ this.isLoaded = true;
1290
+ }
1291
+ enqueueStreamOperation(operation) {
1292
+ const run = async () => {
1293
+ try {
1294
+ await this.waitUntilInitialized();
1295
+ return await operation();
1296
+ }
1297
+ catch (error) {
1298
+ const resolvedError = normalizeSubtitleError(error, { format: 'pgs' });
1299
+ this.emitEvent({ type: 'error', format: 'pgs', error: resolvedError });
1300
+ this.onError?.(resolvedError);
1301
+ throw resolvedError;
1302
+ }
1303
+ };
1304
+ const result = this.streamOperationQueue.then(run, run);
1305
+ this.streamOperationQueue = result.then(() => undefined, () => undefined);
1306
+ return result;
1307
+ }
1308
+ /** Append a chunk to the active PGS stream and return the number of newly indexed cues. */
1309
+ append(data) {
1310
+ return this.enqueueStreamOperation(async () => {
1311
+ const chunk = data instanceof Uint8Array ? data : new Uint8Array(data);
1312
+ if (chunk.byteLength === 0)
1313
+ return 0;
1314
+ let added;
1315
+ if (this.state.useWorker && this.state.workerReady && this.state.sessionId) {
1316
+ const response = await sendToWorker({
1317
+ type: 'appendPgs',
1318
+ sessionId: this.state.sessionId,
1319
+ data: createTransferableBuffer(chunk, true)
1320
+ });
1321
+ if (response.type === 'error')
1322
+ throw new Error(response.message);
1323
+ if (response.type !== 'pgsProgress')
1324
+ throw new Error('Unexpected PGS worker response');
1325
+ added = response.added;
1326
+ if (added > 0) {
1327
+ this.applyPgsIndexState(response.metadata, response.timestamps, true, true);
1328
+ }
1329
+ else {
1330
+ this.state.metadata = response.metadata;
1331
+ this.state.timestamps = response.timestamps;
1332
+ }
1333
+ }
1334
+ else {
1335
+ if (!this.pgsParser)
1336
+ throw new Error('PGS parser is not initialized');
1337
+ added = this.pgsParser.feed(chunk);
1338
+ if (added > 0) {
1339
+ this.applyPgsIndexState(this.pgsParser.getMetadata(), this.pgsParser.getTimestamps(), true, false);
1340
+ }
1341
+ }
1342
+ if (added > 0)
1343
+ this.refreshStreamPresentation();
1344
+ return added;
1345
+ });
1346
+ }
1347
+ /** Flush incomplete trailing PGS input and return the total indexed cue count. */
1348
+ flush() {
1349
+ return this.enqueueStreamOperation(async () => {
1350
+ let count;
1351
+ if (this.state.useWorker && this.state.workerReady && this.state.sessionId) {
1352
+ const response = await sendToWorker({ type: 'finishPgs', sessionId: this.state.sessionId });
1353
+ if (response.type === 'error')
1354
+ throw new Error(response.message);
1355
+ if (response.type !== 'pgsProgress')
1356
+ throw new Error('Unexpected PGS worker response');
1357
+ count = response.count;
1358
+ this.applyPgsIndexState(response.metadata, response.timestamps, false, true);
1359
+ }
1360
+ else {
1361
+ if (!this.pgsParser)
1362
+ throw new Error('PGS parser is not initialized');
1363
+ count = this.pgsParser.finishFeed();
1364
+ this.applyPgsIndexState(this.pgsParser.getMetadata(), this.pgsParser.getTimestamps(), false, false);
1365
+ }
1366
+ this.refreshStreamPresentation();
1367
+ return count;
1368
+ });
1369
+ }
1370
+ /** Reset the PGS stream while keeping the renderer and canvas ready for more chunks. */
1371
+ reset() {
1372
+ return this.enqueueStreamOperation(async () => {
1373
+ this.streamGeneration++;
1374
+ try {
1375
+ if (this.state.useWorker && this.state.workerReady && this.state.sessionId) {
1376
+ const response = await sendToWorker({ type: 'resetPgs', sessionId: this.state.sessionId });
1377
+ if (response.type === 'error')
1378
+ throw new Error(response.message);
1379
+ if (response.type !== 'pgsProgress')
1380
+ throw new Error('Unexpected PGS worker response');
1381
+ this.applyPgsIndexState(response.metadata, response.timestamps, true, true);
1382
+ }
1383
+ else {
1384
+ if (!this.pgsParser)
1385
+ throw new Error('PGS parser is not initialized');
1386
+ this.pgsParser.reset();
1387
+ this.applyPgsIndexState(this.pgsParser.getMetadata(), this.pgsParser.getTimestamps(), true, false);
1388
+ }
1389
+ this.state.frameCache.clear();
1390
+ this.state.renderIssues.clear();
1391
+ this.state.pendingRenders.clear();
1392
+ this.clearStreamPresentation();
1393
+ this.emitCacheChange(0, 0);
1394
+ }
1395
+ finally {
1396
+ this.streamGeneration++;
1397
+ }
1398
+ });
1399
+ }
778
1400
  async loadPgsBuffer(data, preserveSource) {
779
1401
  if (this.state.useWorker) {
780
1402
  try {
@@ -973,6 +1595,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
973
1595
  }
974
1596
  if (this.state.useWorker && this.state.workerReady) {
975
1597
  if (!this.state.pendingRenders.has(index)) {
1598
+ const generation = this.streamGeneration;
976
1599
  const renderTask = sendToWorker({
977
1600
  type: 'renderPgsAtIndex',
978
1601
  sessionId: this.state.sessionId,
@@ -990,15 +1613,14 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
990
1613
  this.state.pendingRenders.set(index, renderPromise);
991
1614
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
992
1615
  renderTask.then(({ frame, renderIssue }) => {
1616
+ if (generation !== this.streamGeneration || this.disposed)
1617
+ return;
993
1618
  setCachedFrame(this.state, index, frame, renderIssue);
994
1619
  this.state.pendingRenders.delete(index);
995
1620
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
996
- // Force re-render on next frame by resetting lastRenderedIndex
997
- if (this.findCurrentIndex(this.video.currentTime + this.timeOffset) === index) {
998
- this.lastRenderedIndex = -1;
999
- }
1000
1621
  });
1001
1622
  }
1623
+ this.watchPendingRender(index, this.state.pendingRenders.get(index));
1002
1624
  // Return undefined to indicate async loading in progress
1003
1625
  return undefined;
1004
1626
  }
@@ -1017,6 +1639,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1017
1639
  const startTime = this.state.timestamps[index];
1018
1640
  const endTime = this.state.timestamps[index + 1] ?? startTime + 5000;
1019
1641
  const frame = this.state.frameCache.get(index) ?? null;
1642
+ const offscreenFrame = this.getOffscreenFrameMetadata(index);
1020
1643
  return {
1021
1644
  index,
1022
1645
  format: 'pgs',
@@ -1025,8 +1648,8 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1025
1648
  duration: Math.max(0, endTime - startTime),
1026
1649
  screenWidth: metadata.screenWidth,
1027
1650
  screenHeight: metadata.screenHeight,
1028
- bounds: frame ? getSubtitleBounds(frame) : null,
1029
- compositionCount: frame?.compositionData.length ?? 0
1651
+ bounds: frame ? getSubtitleBounds(frame) : (offscreenFrame?.bounds ?? null),
1652
+ compositionCount: frame?.compositionData.length ?? offscreenFrame?.compositionCount ?? 0
1030
1653
  };
1031
1654
  }
1032
1655
  isPendingRender(index) {
@@ -1047,15 +1670,18 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1047
1670
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
1048
1671
  }
1049
1672
  clearFrameCache() {
1673
+ this.invalidatePresentation();
1050
1674
  this.state.frameCache.clear();
1051
1675
  this.state.renderIssues.clear();
1052
1676
  this.state.pendingRenders.clear();
1677
+ this.clearOffscreenFrameMetadata();
1053
1678
  this.lastRenderedIndex = -1;
1054
1679
  if (this.state.useWorker && this.state.workerReady) {
1055
1680
  sendToWorker({ type: 'clearPgsCache', sessionId: this.state.sessionId }).catch(() => { });
1056
1681
  }
1057
1682
  this.pgsParser?.clearCache();
1058
1683
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
1684
+ this.renderPausedFrame();
1059
1685
  }
1060
1686
  async prefetchRange(startIndex, endIndex) {
1061
1687
  const safeStart = Math.max(0, Math.min(startIndex, endIndex));
@@ -1087,6 +1713,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1087
1713
  };
1088
1714
  }
1089
1715
  dispose() {
1716
+ this.streamGeneration++;
1090
1717
  super.dispose();
1091
1718
  this.state.frameCache.clear();
1092
1719
  this.state.renderIssues.clear();
@@ -1427,11 +2054,14 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1427
2054
  }
1428
2055
  // Start async lookup if not already pending
1429
2056
  if (!this.pendingIndexLookup) {
1430
- this.pendingIndexLookup = sendToWorker({
2057
+ const presentationToken = this.getPresentationToken();
2058
+ const lookup = sendToWorker({
1431
2059
  type: 'findVobSubIndex',
1432
2060
  sessionId: this.state.sessionId,
1433
2061
  timeMs
1434
2062
  }).then((response) => {
2063
+ if (!this.isCurrentPresentation(presentationToken))
2064
+ return this.cachedIndex;
1435
2065
  if (response.type === 'vobSubIndex') {
1436
2066
  const newIndex = response.index;
1437
2067
  const oldIndex = this.cachedIndex;
@@ -1442,9 +2072,18 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1442
2072
  this.lastRenderedIndex = -2; // Use -2 to force update even when new index is -1
1443
2073
  }
1444
2074
  }
1445
- this.pendingIndexLookup = null;
1446
2075
  return this.cachedIndex;
1447
2076
  });
2077
+ this.pendingIndexLookup = lookup;
2078
+ void lookup.then(() => {
2079
+ if (this.pendingIndexLookup !== lookup)
2080
+ return;
2081
+ this.pendingIndexLookup = null;
2082
+ this.renderPausedFrame();
2083
+ }, () => {
2084
+ if (this.pendingIndexLookup === lookup)
2085
+ this.pendingIndexLookup = null;
2086
+ });
1448
2087
  }
1449
2088
  return this.cachedIndex;
1450
2089
  }
@@ -1477,12 +2116,9 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1477
2116
  setCachedFrame(this.state, index, frame, renderIssue);
1478
2117
  this.state.pendingRenders.delete(index);
1479
2118
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
1480
- // Force re-render on next frame by resetting lastRenderedIndex
1481
- if (this.findCurrentIndex(this.video.currentTime + this.timeOffset) === index) {
1482
- this.lastRenderedIndex = -1;
1483
- }
1484
2119
  });
1485
2120
  }
2121
+ this.watchPendingRender(index, this.state.pendingRenders.get(index));
1486
2122
  // Return undefined to indicate async loading in progress
1487
2123
  return undefined;
1488
2124
  }
@@ -1501,6 +2137,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1501
2137
  const startTime = this.state.timestamps[index];
1502
2138
  const endTime = this.state.timestamps[index + 1] ?? startTime + 5000;
1503
2139
  const frame = this.state.frameCache.get(index) ?? null;
2140
+ const offscreenFrame = this.getOffscreenFrameMetadata(index);
1504
2141
  return {
1505
2142
  index,
1506
2143
  format: 'vobsub',
@@ -1509,8 +2146,8 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1509
2146
  duration: Math.max(0, endTime - startTime),
1510
2147
  screenWidth: metadata.screenWidth,
1511
2148
  screenHeight: metadata.screenHeight,
1512
- bounds: frame ? getSubtitleBounds(frame) : null,
1513
- compositionCount: frame?.compositionData.length ?? 0,
2149
+ bounds: frame ? getSubtitleBounds(frame) : (offscreenFrame?.bounds ?? null),
2150
+ compositionCount: frame?.compositionData.length ?? offscreenFrame?.compositionCount ?? 0,
1514
2151
  language: metadata.language ?? null,
1515
2152
  trackId: metadata.trackId ?? null
1516
2153
  };
@@ -1537,9 +2174,11 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1537
2174
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
1538
2175
  }
1539
2176
  clearFrameCache() {
2177
+ this.invalidatePresentation();
1540
2178
  this.state.frameCache.clear();
1541
2179
  this.state.renderIssues.clear();
1542
2180
  this.state.pendingRenders.clear();
2181
+ this.clearOffscreenFrameMetadata();
1543
2182
  this.cachedIndex = -1;
1544
2183
  this.cachedIndexTime = -1;
1545
2184
  this.pendingIndexLookup = null;
@@ -1549,6 +2188,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1549
2188
  }
1550
2189
  this.vobsubParser?.clearCache();
1551
2190
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
2191
+ this.renderPausedFrame();
1552
2192
  }
1553
2193
  async prefetchRange(startIndex, endIndex) {
1554
2194
  const safeStart = Math.max(0, Math.min(startIndex, endIndex));
@@ -1585,10 +2225,13 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1585
2225
  sendToWorker({ type: 'setVobSubDebandEnabled', sessionId: this.state.sessionId, enabled }).catch(() => { });
1586
2226
  }
1587
2227
  this.vobsubParser?.setDebandEnabled(enabled);
2228
+ this.invalidatePresentation();
1588
2229
  // Clear cache to force re-render with new settings
1589
2230
  this.state.frameCache.clear();
2231
+ this.clearOffscreenFrameMetadata();
1590
2232
  this.lastRenderedIndex = -1;
1591
2233
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
2234
+ this.renderPausedFrame();
1592
2235
  }
1593
2236
  /** Set debanding threshold (0-255, default: 64) */
1594
2237
  setDebandThreshold(threshold) {
@@ -1596,10 +2239,13 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1596
2239
  sendToWorker({ type: 'setVobSubDebandThreshold', sessionId: this.state.sessionId, threshold }).catch(() => { });
1597
2240
  }
1598
2241
  this.vobsubParser?.setDebandThreshold(threshold);
2242
+ this.invalidatePresentation();
1599
2243
  // Clear cache to force re-render with new settings
1600
2244
  this.state.frameCache.clear();
2245
+ this.clearOffscreenFrameMetadata();
1601
2246
  this.lastRenderedIndex = -1;
1602
2247
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
2248
+ this.renderPausedFrame();
1603
2249
  }
1604
2250
  /** Set debanding sample range in pixels (1-64, default: 15) */
1605
2251
  setDebandRange(range) {
@@ -1607,10 +2253,13 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1607
2253
  sendToWorker({ type: 'setVobSubDebandRange', sessionId: this.state.sessionId, range }).catch(() => { });
1608
2254
  }
1609
2255
  this.vobsubParser?.setDebandRange(range);
2256
+ this.invalidatePresentation();
1610
2257
  // Clear cache to force re-render with new settings
1611
2258
  this.state.frameCache.clear();
2259
+ this.clearOffscreenFrameMetadata();
1612
2260
  this.lastRenderedIndex = -1;
1613
2261
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
2262
+ this.renderPausedFrame();
1614
2263
  }
1615
2264
  /** Check if debanding is enabled */
1616
2265
  get debandEnabled() {
@@ -1629,17 +2278,571 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1629
2278
  this.state.sessionId = null;
1630
2279
  }
1631
2280
  }
1632
- /** Create a video subtitle renderer with automatic format detection. */
1633
- export function createAutoSubtitleRenderer(options) {
1634
- const format = detectSubtitleFormat({
1635
- data: options.subContent,
1636
- idxContent: options.idxContent,
1637
- fileName: options.fileName,
1638
- subUrl: options.subUrl,
1639
- idxUrl: options.idxUrl
1640
- });
1641
- if (format === 'pgs') {
1642
- return new PgsRenderer(options);
2281
+ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2282
+ dvbParser = null;
2283
+ endTimestamps = new Float64Array(0);
2284
+ state = createWorkerState();
2285
+ streamOperationQueue = Promise.resolve();
2286
+ streamGeneration = 0;
2287
+ onLoading;
2288
+ onLoaded;
2289
+ onError;
2290
+ constructor(options) {
2291
+ super(options, 'dvb');
2292
+ this.onLoading = options.onLoading;
2293
+ this.onLoaded = options.onLoaded;
2294
+ this.onError = options.onError;
2295
+ applyCacheLimit(this.state, this.cacheLimit);
2296
+ this.startInit();
2297
+ }
2298
+ async loadSubtitles() {
2299
+ try {
2300
+ this.emitEvent({ type: 'loading', format: 'dvb' });
2301
+ this.onLoading?.();
2302
+ if (this.subContent) {
2303
+ const data = new Uint8Array(this.subContent);
2304
+ this.emitLoadProgress('dvb', this.memoryProgress(data.byteLength), 0);
2305
+ await this.loadDvbBuffer(data, true);
2306
+ this.onLoaded?.();
2307
+ return;
2308
+ }
2309
+ if (!this.subUrl) {
2310
+ await this.beginDvbPushSession();
2311
+ this.onLoaded?.();
2312
+ return;
2313
+ }
2314
+ if (!this.streamingLoad) {
2315
+ const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(this.subUrl, {
2316
+ preferRange: this.rangeRequests,
2317
+ onProgress: (progress) => this.emitLoadProgress('dvb', progress, this.state.timestamps.length)
2318
+ });
2319
+ this.emitLoadProgress('dvb', { loaded: data.byteLength, total: total ?? data.byteLength, ratio: 1, rangeSupported, strategy }, 0);
2320
+ await this.loadDvbBuffer(data, false);
2321
+ this.onLoaded?.();
2322
+ return;
2323
+ }
2324
+ await this.loadDvbStreaming(this.subUrl);
2325
+ this.onLoaded?.();
2326
+ }
2327
+ catch (error) {
2328
+ const resolvedError = normalizeSubtitleError(error, { format: 'dvb' });
2329
+ this.emitEvent({ type: 'error', format: 'dvb', error: resolvedError });
2330
+ this.onError?.(resolvedError);
2331
+ }
2332
+ }
2333
+ applyDvbIndexState(metadata, timestamps, endTimestamps, partial, usingWorker) {
2334
+ this.state.metadata = metadata;
2335
+ this.state.timestamps = timestamps;
2336
+ this.endTimestamps = endTimestamps;
2337
+ this.setParserMetadata(metadata);
2338
+ this.emitIndexed('dvb', metadata, partial);
2339
+ if (!this.isLoaded && timestamps.length > 0) {
2340
+ this.isLoaded = true;
2341
+ if (usingWorker) {
2342
+ this.state.workerReady = true;
2343
+ this.emitWorkerState(true, true, this.state.sessionId);
2344
+ }
2345
+ }
2346
+ }
2347
+ async beginDvbPushSession() {
2348
+ if (this.state.useWorker) {
2349
+ try {
2350
+ this.state.sessionId = createWorkerSessionId();
2351
+ await getOrCreateWorker();
2352
+ this.emitWorkerState(true, false, this.state.sessionId);
2353
+ const response = await sendToWorker({ type: 'beginDvb', sessionId: this.state.sessionId });
2354
+ if (response.type === 'error')
2355
+ throw new Error(response.message);
2356
+ if (response.type !== 'dvbProgress')
2357
+ throw new Error('Unexpected DVB worker response');
2358
+ this.applyDvbIndexState(response.metadata, response.timestamps, response.endTimestamps, true, true);
2359
+ this.state.workerReady = true;
2360
+ this.isLoaded = true;
2361
+ this.emitWorkerState(true, true, this.state.sessionId);
2362
+ return;
2363
+ }
2364
+ catch (workerError) {
2365
+ this.state.useWorker = false;
2366
+ this.state.workerReady = false;
2367
+ this.state.sessionId = null;
2368
+ this.emitWorkerState(false, false, null, true);
2369
+ this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'DVB worker initialization failed, falling back to main-thread rendering.', {
2370
+ format: 'dvb',
2371
+ details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
2372
+ }));
2373
+ }
2374
+ }
2375
+ await this.yieldToMain();
2376
+ this.dvbParser = new DvbParser({ debug: this.debug, onWarning: (warning) => this.emitWarning(warning) });
2377
+ this.dvbParser.reset();
2378
+ const metadata = this.dvbParser.getMetadata();
2379
+ this.applyDvbIndexState(metadata, this.dvbParser.getTimestamps(), this.dvbParser.getEndTimestamps(), true, false);
2380
+ this.isLoaded = true;
2381
+ }
2382
+ enqueueStreamOperation(operation) {
2383
+ const run = async () => {
2384
+ try {
2385
+ await this.waitUntilInitialized();
2386
+ return await operation();
2387
+ }
2388
+ catch (error) {
2389
+ const resolvedError = normalizeSubtitleError(error, { format: 'dvb' });
2390
+ this.emitEvent({ type: 'error', format: 'dvb', error: resolvedError });
2391
+ this.onError?.(resolvedError);
2392
+ throw resolvedError;
2393
+ }
2394
+ };
2395
+ const result = this.streamOperationQueue.then(run, run);
2396
+ this.streamOperationQueue = result.then(() => undefined, () => undefined);
2397
+ return result;
2398
+ }
2399
+ /** Append a chunk to the active DVB stream and return the number of newly indexed cues. */
2400
+ append(data) {
2401
+ return this.enqueueStreamOperation(async () => {
2402
+ const chunk = data instanceof Uint8Array ? data : new Uint8Array(data);
2403
+ if (chunk.byteLength === 0)
2404
+ return 0;
2405
+ let added;
2406
+ if (this.state.useWorker && this.state.workerReady && this.state.sessionId) {
2407
+ const response = await sendToWorker({
2408
+ type: 'appendDvb',
2409
+ sessionId: this.state.sessionId,
2410
+ data: createTransferableBuffer(chunk, true)
2411
+ });
2412
+ if (response.type === 'error')
2413
+ throw new Error(response.message);
2414
+ if (response.type !== 'dvbProgress')
2415
+ throw new Error('Unexpected DVB worker response');
2416
+ added = response.added;
2417
+ if (added > 0) {
2418
+ this.applyDvbIndexState(response.metadata, response.timestamps, response.endTimestamps, true, true);
2419
+ }
2420
+ else {
2421
+ this.state.metadata = response.metadata;
2422
+ this.state.timestamps = response.timestamps;
2423
+ this.endTimestamps = response.endTimestamps;
2424
+ }
2425
+ }
2426
+ else {
2427
+ if (!this.dvbParser)
2428
+ throw new Error('DVB parser is not initialized');
2429
+ added = this.dvbParser.feed(chunk);
2430
+ if (added > 0) {
2431
+ this.applyDvbIndexState(this.dvbParser.getMetadata(), this.dvbParser.getTimestamps(), this.dvbParser.getEndTimestamps(), true, false);
2432
+ }
2433
+ }
2434
+ if (added > 0)
2435
+ this.refreshStreamPresentation();
2436
+ return added;
2437
+ });
2438
+ }
2439
+ /** Flush incomplete trailing DVB input and return the total indexed cue count. */
2440
+ flush() {
2441
+ return this.enqueueStreamOperation(async () => {
2442
+ let count;
2443
+ if (this.state.useWorker && this.state.workerReady && this.state.sessionId) {
2444
+ const response = await sendToWorker({ type: 'finishDvb', sessionId: this.state.sessionId });
2445
+ if (response.type === 'error')
2446
+ throw new Error(response.message);
2447
+ if (response.type !== 'dvbProgress')
2448
+ throw new Error('Unexpected DVB worker response');
2449
+ count = response.count;
2450
+ this.applyDvbIndexState(response.metadata, response.timestamps, response.endTimestamps, false, true);
2451
+ }
2452
+ else {
2453
+ if (!this.dvbParser)
2454
+ throw new Error('DVB parser is not initialized');
2455
+ count = this.dvbParser.finishFeed();
2456
+ this.applyDvbIndexState(this.dvbParser.getMetadata(), this.dvbParser.getTimestamps(), this.dvbParser.getEndTimestamps(), false, false);
2457
+ }
2458
+ this.refreshStreamPresentation();
2459
+ return count;
2460
+ });
2461
+ }
2462
+ /** Reset the DVB stream while keeping the renderer and canvas ready for more chunks. */
2463
+ reset() {
2464
+ return this.enqueueStreamOperation(async () => {
2465
+ this.streamGeneration++;
2466
+ try {
2467
+ if (this.state.useWorker && this.state.workerReady && this.state.sessionId) {
2468
+ const response = await sendToWorker({ type: 'resetDvb', sessionId: this.state.sessionId });
2469
+ if (response.type === 'error')
2470
+ throw new Error(response.message);
2471
+ if (response.type !== 'dvbProgress')
2472
+ throw new Error('Unexpected DVB worker response');
2473
+ this.applyDvbIndexState(response.metadata, response.timestamps, response.endTimestamps, true, true);
2474
+ }
2475
+ else {
2476
+ if (!this.dvbParser)
2477
+ throw new Error('DVB parser is not initialized');
2478
+ this.dvbParser.reset();
2479
+ this.applyDvbIndexState(this.dvbParser.getMetadata(), this.dvbParser.getTimestamps(), this.dvbParser.getEndTimestamps(), true, false);
2480
+ }
2481
+ this.state.frameCache.clear();
2482
+ this.state.renderIssues.clear();
2483
+ this.state.pendingRenders.clear();
2484
+ this.clearStreamPresentation();
2485
+ this.emitCacheChange(0, 0);
2486
+ }
2487
+ finally {
2488
+ this.streamGeneration++;
2489
+ }
2490
+ });
2491
+ }
2492
+ async loadDvbBuffer(data, preserveSource) {
2493
+ if (this.state.useWorker) {
2494
+ try {
2495
+ this.state.sessionId = createWorkerSessionId();
2496
+ await getOrCreateWorker();
2497
+ this.emitWorkerState(true, false, this.state.sessionId);
2498
+ const transferableData = createTransferableBuffer(data, preserveSource);
2499
+ const loadResponse = await sendToWorker({
2500
+ type: 'loadDvb',
2501
+ sessionId: this.state.sessionId,
2502
+ data: transferableData
2503
+ });
2504
+ if (loadResponse.type === 'dvbLoaded') {
2505
+ this.state.workerReady = true;
2506
+ this.state.metadata = loadResponse.metadata;
2507
+ this.state.timestamps = loadResponse.timestamps;
2508
+ this.endTimestamps = loadResponse.endTimestamps;
2509
+ this.isLoaded = true;
2510
+ this.setParserMetadata(loadResponse.metadata);
2511
+ this.emitIndexed('dvb', loadResponse.metadata, false);
2512
+ this.emitWorkerState(true, true, this.state.sessionId);
2513
+ return;
2514
+ }
2515
+ else if (loadResponse.type === 'error') {
2516
+ throw new Error(loadResponse.message);
2517
+ }
2518
+ }
2519
+ catch (workerError) {
2520
+ this.state.useWorker = false;
2521
+ this.emitWorkerState(false, false, this.state.sessionId, true);
2522
+ this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'DVB worker initialization failed, falling back to main-thread rendering.', {
2523
+ format: 'dvb',
2524
+ details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
2525
+ }));
2526
+ }
2527
+ }
2528
+ await this.loadOnMainThread(data);
2529
+ }
2530
+ async loadDvbStreaming(url) {
2531
+ let usedWorker = false;
2532
+ let indexedOnce = false;
2533
+ if (this.state.useWorker) {
2534
+ try {
2535
+ this.state.sessionId = createWorkerSessionId();
2536
+ await getOrCreateWorker();
2537
+ this.emitWorkerState(true, false, this.state.sessionId);
2538
+ const begin = await sendToWorker({ type: 'beginDvb', sessionId: this.state.sessionId });
2539
+ if (begin.type === 'error')
2540
+ throw new Error(begin.message);
2541
+ usedWorker = true;
2542
+ }
2543
+ catch (workerError) {
2544
+ this.state.useWorker = false;
2545
+ usedWorker = false;
2546
+ this.emitWorkerState(false, false, this.state.sessionId, true);
2547
+ this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'DVB worker initialization failed, falling back to main-thread rendering.', {
2548
+ format: 'dvb',
2549
+ details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
2550
+ }));
2551
+ }
2552
+ }
2553
+ if (!usedWorker) {
2554
+ await this.yieldToMain();
2555
+ this.dvbParser = new DvbParser({ debug: this.debug, onWarning: (warning) => this.emitWarning(warning) });
2556
+ this.dvbParser.reset();
2557
+ }
2558
+ try {
2559
+ const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(url, {
2560
+ preferRange: this.rangeRequests,
2561
+ onProgress: (progress) => this.emitLoadProgress('dvb', progress, this.state.timestamps.length)
2562
+ }, async (chunk, progress) => {
2563
+ if (chunk.byteLength === 0)
2564
+ return;
2565
+ if (usedWorker && this.state.sessionId) {
2566
+ const transferable = createTransferableBuffer(chunk, true);
2567
+ const response = await sendToWorker({
2568
+ type: 'appendDvb',
2569
+ sessionId: this.state.sessionId,
2570
+ data: transferable
2571
+ });
2572
+ if (response.type === 'dvbProgress') {
2573
+ if (response.added > 0 || !indexedOnce) {
2574
+ this.applyDvbIndexState(response.metadata, response.timestamps, response.endTimestamps, true, true);
2575
+ indexedOnce = true;
2576
+ }
2577
+ else {
2578
+ this.state.timestamps = response.timestamps;
2579
+ this.endTimestamps = response.endTimestamps;
2580
+ this.state.metadata = response.metadata;
2581
+ }
2582
+ }
2583
+ else if (response.type === 'error') {
2584
+ throw new Error(response.message);
2585
+ }
2586
+ }
2587
+ else if (this.dvbParser) {
2588
+ const added = this.dvbParser.feed(chunk);
2589
+ if (added > 0 || !indexedOnce) {
2590
+ const metadata = this.dvbParser.getMetadata();
2591
+ this.applyDvbIndexState(metadata, this.dvbParser.getTimestamps(), this.dvbParser.getEndTimestamps(), true, false);
2592
+ indexedOnce = true;
2593
+ }
2594
+ }
2595
+ this.emitLoadProgress('dvb', progress, this.state.timestamps.length);
2596
+ });
2597
+ if (usedWorker && this.state.sessionId) {
2598
+ const finish = await sendToWorker({ type: 'finishDvb', sessionId: this.state.sessionId });
2599
+ if (finish.type === 'dvbProgress') {
2600
+ this.applyDvbIndexState(finish.metadata, finish.timestamps, finish.endTimestamps, false, true);
2601
+ this.state.workerReady = true;
2602
+ this.isLoaded = true;
2603
+ this.emitWorkerState(true, true, this.state.sessionId);
2604
+ }
2605
+ else if (finish.type === 'error') {
2606
+ throw new Error(finish.message);
2607
+ }
2608
+ }
2609
+ else if (this.dvbParser) {
2610
+ this.dvbParser.finishFeed();
2611
+ const metadata = this.dvbParser.getMetadata();
2612
+ this.state.timestamps = this.dvbParser.getTimestamps();
2613
+ this.endTimestamps = this.dvbParser.getEndTimestamps();
2614
+ this.state.metadata = metadata;
2615
+ this.isLoaded = true;
2616
+ this.setParserMetadata(metadata);
2617
+ this.emitIndexed('dvb', metadata, false);
2618
+ if (metadata.cueCount === 0) {
2619
+ this.state.renderIssues.set(-1, 'INVALID_SUBTITLE_DATA');
2620
+ }
2621
+ }
2622
+ this.emitLoadProgress('dvb', {
2623
+ loaded: data.byteLength,
2624
+ total: total ?? data.byteLength,
2625
+ ratio: 1,
2626
+ rangeSupported,
2627
+ strategy: strategy
2628
+ }, this.state.timestamps.length);
2629
+ }
2630
+ catch (error) {
2631
+ if (usedWorker) {
2632
+ this.state.useWorker = false;
2633
+ this.emitWorkerState(false, false, this.state.sessionId, true);
2634
+ }
2635
+ this.emitWarning(createSubtitleWarning('RANGE_FALLBACK', 'Progressive DVB load failed; retrying with a full buffer fetch.', {
2636
+ format: 'dvb',
2637
+ details: { reason: error instanceof Error ? error.message : String(error) }
2638
+ }));
2639
+ const { data } = await fetchSubtitleAsset(url, { preferRange: this.rangeRequests });
2640
+ await this.loadDvbBuffer(data, false);
2641
+ }
2642
+ }
2643
+ async loadOnMainThread(data) {
2644
+ await this.yieldToMain();
2645
+ this.dvbParser = new DvbParser({ debug: this.debug, onWarning: (warning) => this.emitWarning(warning) });
2646
+ await new Promise((resolve) => {
2647
+ const scheduleTask = typeof requestIdleCallback !== 'undefined'
2648
+ ? (cb) => requestIdleCallback(() => cb(), { timeout: 1000 })
2649
+ : (cb) => setTimeout(cb, 0);
2650
+ scheduleTask(() => {
2651
+ const count = this.dvbParser.load(data);
2652
+ this.state.timestamps = this.dvbParser.getTimestamps();
2653
+ this.endTimestamps = this.dvbParser.getEndTimestamps();
2654
+ this.state.metadata = this.dvbParser.getMetadata();
2655
+ this.isLoaded = true;
2656
+ this.setParserMetadata(this.state.metadata);
2657
+ this.emitIndexed('dvb', this.state.metadata, false);
2658
+ if (count === 0) {
2659
+ this.state.renderIssues.set(-1, 'INVALID_SUBTITLE_DATA');
2660
+ }
2661
+ resolve();
2662
+ });
2663
+ });
2664
+ }
2665
+ getWorkerRendererState() {
2666
+ return this.state;
2667
+ }
2668
+ /** Yield to main thread to prevent UI blocking */
2669
+ yieldToMain() {
2670
+ // Use scheduler.yield if available (Chrome 115+)
2671
+ const globalScheduler = globalThis.scheduler;
2672
+ if (globalScheduler && typeof globalScheduler.yield === 'function') {
2673
+ return globalScheduler.yield();
2674
+ }
2675
+ // Fallback to setTimeout
2676
+ return new Promise((resolve) => setTimeout(resolve, 0));
2677
+ }
2678
+ renderAtTime(time) {
2679
+ const index = this.findCurrentIndex(time);
2680
+ return index < 0 ? undefined : this.renderAtIndex(index);
2681
+ }
2682
+ findCurrentIndex(time) {
2683
+ if (this.state.useWorker && this.state.workerReady) {
2684
+ const timeMs = time * 1000;
2685
+ const index = binarySearchTimestamp(this.state.timestamps, timeMs);
2686
+ return index >= 0 && timeMs < (this.endTimestamps[index] ?? this.state.timestamps[index]) ? index : -1;
2687
+ }
2688
+ return this.dvbParser?.findIndexAtTimestamp(time) ?? -1;
2689
+ }
2690
+ renderAtIndex(index) {
2691
+ if (this.state.frameCache.has(index)) {
2692
+ return this.state.frameCache.get(index) ?? undefined;
2693
+ }
2694
+ if (this.state.useWorker && this.state.workerReady) {
2695
+ if (!this.state.pendingRenders.has(index)) {
2696
+ const generation = this.streamGeneration;
2697
+ const renderTask = sendToWorker({
2698
+ type: 'renderDvbAtIndex',
2699
+ sessionId: this.state.sessionId,
2700
+ index
2701
+ }).then((response) => {
2702
+ if (response.type === 'dvbFrame') {
2703
+ return {
2704
+ frame: response.frame ? convertFrameData(response.frame) : null,
2705
+ renderIssue: response.renderIssue?.trim() || null
2706
+ };
2707
+ }
2708
+ return { frame: null, renderIssue: null };
2709
+ });
2710
+ const renderPromise = renderTask.then(({ frame }) => frame);
2711
+ this.state.pendingRenders.set(index, renderPromise);
2712
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
2713
+ renderTask.then(({ frame, renderIssue }) => {
2714
+ if (generation !== this.streamGeneration || this.disposed)
2715
+ return;
2716
+ setCachedFrame(this.state, index, frame, renderIssue);
2717
+ this.state.pendingRenders.delete(index);
2718
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
2719
+ });
2720
+ }
2721
+ this.watchPendingRender(index, this.state.pendingRenders.get(index));
2722
+ // Return undefined to indicate async loading in progress
2723
+ return undefined;
2724
+ }
2725
+ const rendered = this.dvbParser?.renderAtIndex(index) ?? null;
2726
+ setCachedFrame(this.state, index, rendered, this.dvbParser?.getLastRenderIssue() ?? null);
2727
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
2728
+ return rendered ?? undefined;
2729
+ }
2730
+ buildCueMetadata(index) {
2731
+ if (this.dvbParser) {
2732
+ return this.dvbParser.getCueMetadata(index);
2733
+ }
2734
+ const metadata = this.state.metadata;
2735
+ if (!metadata || index < 0 || index >= this.state.timestamps.length)
2736
+ return null;
2737
+ const startTime = this.state.timestamps[index];
2738
+ const endTime = this.endTimestamps[index] ?? startTime;
2739
+ const frame = this.state.frameCache.get(index) ?? null;
2740
+ const offscreenFrame = this.getOffscreenFrameMetadata(index);
2741
+ return {
2742
+ index,
2743
+ format: 'dvb',
2744
+ startTime,
2745
+ endTime,
2746
+ duration: Math.max(0, endTime - startTime),
2747
+ screenWidth: metadata.screenWidth,
2748
+ screenHeight: metadata.screenHeight,
2749
+ bounds: frame ? getSubtitleBounds(frame) : (offscreenFrame?.bounds ?? null),
2750
+ compositionCount: frame?.compositionData.length ?? offscreenFrame?.compositionCount ?? 0
2751
+ };
2752
+ }
2753
+ isPendingRender(index) {
2754
+ return this.state.pendingRenders.has(index);
2755
+ }
2756
+ onSeek() {
2757
+ this.state.frameCache.clear();
2758
+ this.state.renderIssues.clear();
2759
+ this.state.pendingRenders.clear();
2760
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
2761
+ if (this.state.useWorker && this.state.workerReady) {
2762
+ sendToWorker({ type: 'clearDvbCache', sessionId: this.state.sessionId }).catch(() => { });
2763
+ }
2764
+ this.dvbParser?.clearCache();
2765
+ }
2766
+ setCacheLimit(limit) {
2767
+ this.cacheLimit = applyCacheLimit(this.state, limit);
2768
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
2769
+ }
2770
+ clearFrameCache() {
2771
+ this.invalidatePresentation();
2772
+ this.state.frameCache.clear();
2773
+ this.state.renderIssues.clear();
2774
+ this.state.pendingRenders.clear();
2775
+ this.clearOffscreenFrameMetadata();
2776
+ this.lastRenderedIndex = -1;
2777
+ if (this.state.useWorker && this.state.workerReady) {
2778
+ sendToWorker({ type: 'clearDvbCache', sessionId: this.state.sessionId }).catch(() => { });
2779
+ }
2780
+ this.dvbParser?.clearCache();
2781
+ this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
2782
+ this.renderPausedFrame();
2783
+ }
2784
+ async prefetchRange(startIndex, endIndex) {
2785
+ const safeStart = Math.max(0, Math.min(startIndex, endIndex));
2786
+ const safeEnd = Math.min(Math.max(startIndex, endIndex), this.state.timestamps.length - 1);
2787
+ for (let index = safeStart; index <= safeEnd; index++) {
2788
+ if (this.state.frameCache.has(index))
2789
+ continue;
2790
+ const result = this.renderAtIndex(index);
2791
+ if (result === undefined && this.state.pendingRenders.has(index)) {
2792
+ await this.state.pendingRenders.get(index);
2793
+ }
2794
+ }
2795
+ }
2796
+ async prefetchAroundTime(time, before = this.prefetchBefore, after = this.prefetchAfter) {
2797
+ const currentIndex = this.findCurrentIndex(time);
2798
+ if (currentIndex < 0)
2799
+ return;
2800
+ await this.prefetchRange(currentIndex - before, currentIndex + after);
2801
+ }
2802
+ /** Get performance statistics for DVB renderer */
2803
+ getStats() {
2804
+ const baseStats = this.getBaseStats();
2805
+ return {
2806
+ ...baseStats,
2807
+ usingWorker: this.state.useWorker && this.state.workerReady,
2808
+ cachedFrames: this.state.frameCache.size,
2809
+ pendingRenders: this.state.pendingRenders.size,
2810
+ totalEntries: this.state.timestamps.length || (this.dvbParser?.getTimestamps().length ?? 0)
2811
+ };
2812
+ }
2813
+ dispose() {
2814
+ this.streamGeneration++;
2815
+ super.dispose();
2816
+ this.state.frameCache.clear();
2817
+ this.state.renderIssues.clear();
2818
+ this.state.pendingRenders.clear();
2819
+ if (this.state.useWorker && this.state.workerReady) {
2820
+ sendToWorker({ type: 'disposeDvb', sessionId: this.state.sessionId }).catch(() => { });
2821
+ }
2822
+ this.dvbParser?.dispose();
2823
+ this.dvbParser = null;
2824
+ this.endTimestamps = new Float64Array(0);
2825
+ this.state.sessionId = null;
2826
+ }
2827
+ }
2828
+ /**
2829
+ * High-level VobSub subtitle renderer with Web Worker support.
2830
+ * Compatible with the old libpgs-js API.
2831
+ */
2832
+ /** Create a video subtitle renderer with automatic format detection. */
2833
+ export function createAutoSubtitleRenderer(options) {
2834
+ const format = detectSubtitleFormat({
2835
+ data: options.subContent,
2836
+ idxContent: options.idxContent,
2837
+ fileName: options.fileName,
2838
+ subUrl: options.subUrl,
2839
+ idxUrl: options.idxUrl
2840
+ });
2841
+ if (format === 'pgs') {
2842
+ return new PgsRenderer(options);
2843
+ }
2844
+ if (format === 'dvb') {
2845
+ return new DvbRenderer(options);
1643
2846
  }
1644
2847
  if (format === 'vobsub') {
1645
2848
  return new VobSubRenderer(options);