libbitsub 1.10.2 → 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 (98) hide show
  1. package/README.md +296 -49
  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/hlsjs.d.ts +45 -0
  7. package/dist/integrations/hlsjs.d.ts.map +1 -0
  8. package/dist/integrations/hlsjs.js +121 -0
  9. package/dist/integrations/hlsjs.js.map +1 -0
  10. package/dist/integrations/index.d.ts +11 -0
  11. package/dist/integrations/index.d.ts.map +1 -0
  12. package/dist/integrations/index.js +11 -0
  13. package/dist/integrations/index.js.map +1 -0
  14. package/dist/integrations/react.d.ts +55 -0
  15. package/dist/integrations/react.d.ts.map +1 -0
  16. package/dist/integrations/react.js +133 -0
  17. package/dist/integrations/react.js.map +1 -0
  18. package/dist/integrations/shaka.d.ts +38 -0
  19. package/dist/integrations/shaka.d.ts.map +1 -0
  20. package/dist/integrations/shaka.js +71 -0
  21. package/dist/integrations/shaka.js.map +1 -0
  22. package/dist/integrations/shared.d.ts +41 -0
  23. package/dist/integrations/shared.d.ts.map +1 -0
  24. package/dist/integrations/shared.js +123 -0
  25. package/dist/integrations/shared.js.map +1 -0
  26. package/dist/integrations/videojs.d.ts +85 -0
  27. package/dist/integrations/videojs.d.ts.map +1 -0
  28. package/dist/integrations/videojs.js +111 -0
  29. package/dist/integrations/videojs.js.map +1 -0
  30. package/dist/ts/capabilities.d.ts +8 -0
  31. package/dist/ts/capabilities.d.ts.map +1 -0
  32. package/dist/ts/capabilities.js +105 -0
  33. package/dist/ts/capabilities.js.map +1 -0
  34. package/dist/ts/diagnostics.d.ts.map +1 -1
  35. package/dist/ts/diagnostics.js.map +1 -1
  36. package/dist/ts/frame-export.d.ts.map +1 -1
  37. package/dist/ts/frame-export.js +4 -4
  38. package/dist/ts/frame-export.js.map +1 -1
  39. package/dist/ts/parsers.d.ts +46 -8
  40. package/dist/ts/parsers.d.ts.map +1 -1
  41. package/dist/ts/parsers.js +305 -14
  42. package/dist/ts/parsers.js.map +1 -1
  43. package/dist/ts/range-loader.d.ts +31 -0
  44. package/dist/ts/range-loader.d.ts.map +1 -0
  45. package/dist/ts/range-loader.js +220 -0
  46. package/dist/ts/range-loader.js.map +1 -0
  47. package/dist/ts/renderers.d.ts +140 -8
  48. package/dist/ts/renderers.d.ts.map +1 -1
  49. package/dist/ts/renderers.js +1773 -287
  50. package/dist/ts/renderers.js.map +1 -1
  51. package/dist/ts/types.d.ts +210 -7
  52. package/dist/ts/types.d.ts.map +1 -1
  53. package/dist/ts/utils.d.ts.map +1 -1
  54. package/dist/ts/utils.js +98 -2
  55. package/dist/ts/utils.js.map +1 -1
  56. package/dist/ts/video-frame-scheduler.d.ts +35 -0
  57. package/dist/ts/video-frame-scheduler.d.ts.map +1 -0
  58. package/dist/ts/video-frame-scheduler.js +98 -0
  59. package/dist/ts/video-frame-scheduler.js.map +1 -0
  60. package/dist/ts/wasm.d.ts +2 -0
  61. package/dist/ts/wasm.d.ts.map +1 -1
  62. package/dist/ts/wasm.js +12 -0
  63. package/dist/ts/wasm.js.map +1 -1
  64. package/dist/ts/webgl2-renderer.d.ts +9 -0
  65. package/dist/ts/webgl2-renderer.d.ts.map +1 -1
  66. package/dist/ts/webgl2-renderer.js +34 -0
  67. package/dist/ts/webgl2-renderer.js.map +1 -1
  68. package/dist/ts/webgpu-renderer.d.ts +9 -0
  69. package/dist/ts/webgpu-renderer.d.ts.map +1 -1
  70. package/dist/ts/webgpu-renderer.js +70 -2
  71. package/dist/ts/webgpu-renderer.js.map +1 -1
  72. package/dist/ts/worker.d.ts +11 -0
  73. package/dist/ts/worker.d.ts.map +1 -1
  74. package/dist/ts/worker.js +620 -158
  75. package/dist/ts/worker.js.map +1 -1
  76. package/dist/wrapper.d.ts +6 -3
  77. package/dist/wrapper.d.ts.map +1 -1
  78. package/dist/wrapper.js +5 -2
  79. package/dist/wrapper.js.map +1 -1
  80. package/examples/README.md +38 -0
  81. package/examples/hlsjs/README.md +53 -0
  82. package/examples/react/README.md +60 -0
  83. package/examples/shaka/README.md +61 -0
  84. package/examples/videojs/README.md +55 -0
  85. package/package.json +66 -8
  86. package/pkg/README.md +296 -49
  87. package/pkg/libbitsub.d.ts +65 -1
  88. package/pkg/libbitsub.js +228 -3
  89. package/pkg/libbitsub_bg.wasm +0 -0
  90. package/pkg/libbitsub_bg.wasm.d.ts +28 -0
  91. package/pkg/package.json +4 -4
  92. package/src/integrations/hlsjs.ts +154 -0
  93. package/src/integrations/index.ts +36 -0
  94. package/src/integrations/react.tsx +176 -0
  95. package/src/integrations/shaka.ts +92 -0
  96. package/src/integrations/shared.ts +171 -0
  97. package/src/integrations/videojs.ts +200 -0
  98. package/src/wrapper.ts +34 -2
@@ -2,13 +2,16 @@
2
2
  * High-level video-integrated subtitle renderers for libbitsub.
3
3
  * Handles canvas overlay, video sync, and subtitle fetching.
4
4
  */
5
- import { createSubtitleDiagnosticError, createSubtitleWarning, formatSubtitleWarningForConsole, normalizeSubtitleError, warningFromRenderIssue } from './diagnostics';
5
+ import { SubtitleDiagnosticError, createSubtitleDiagnosticError, createSubtitleWarning, formatSubtitleWarningForConsole, normalizeSubtitleError, warningFromRenderIssue } from './diagnostics';
6
+ import { fetchSubtitleAsset, fetchSubtitleText } from './range-loader';
6
7
  import { initWasm } from './wasm';
7
8
  import { getOrCreateWorker, sendToWorker } from './worker';
9
+ import { canUseWorkerOffscreenRender } from './capabilities';
8
10
  import { binarySearchTimestamp, convertFrameData, createWorkerSessionId, createWorkerState, detectSubtitleFormat, getSubtitleBounds, isMksSource, setCacheLimit as applyCacheLimit, setCachedFrame } from './utils';
9
- import { PgsParser, VobSubParserLowLevel } from './parsers';
11
+ import { PgsParser, DvbParser, VobSubParserLowLevel } from './parsers';
10
12
  import { WebGPURenderer, isWebGPUSupported } from './webgpu-renderer';
11
13
  import { WebGL2Renderer, isWebGL2Supported } from './webgl2-renderer';
14
+ import { VideoFrameScheduler, supportsFrameAwareSync } from './video-frame-scheduler';
12
15
  /** Default display settings */
13
16
  const DEFAULT_DISPLAY_SETTINGS = {
14
17
  scale: 1.0,
@@ -39,8 +42,14 @@ class BaseVideoSubtitleRenderer {
39
42
  subUrl;
40
43
  subContent;
41
44
  canvas = null;
45
+ providedCanvas;
46
+ requestedContainer;
47
+ overlayContainer = null;
48
+ ownsCanvas = false;
49
+ devicePixelRatioCap;
50
+ backendOption;
42
51
  ctx = null;
43
- animationFrameId = null;
52
+ frameScheduler = null;
44
53
  isLoaded = false;
45
54
  lastRenderedIndex = -1;
46
55
  lastRenderedTime = -1;
@@ -54,11 +63,12 @@ class BaseVideoSubtitleRenderer {
54
63
  parserMetadata = null;
55
64
  /** Display settings for subtitle rendering */
56
65
  displaySettings = { ...DEFAULT_DISPLAY_SETTINGS };
57
- /** Time offset in seconds added to video.currentTime for subtitle lookup */
58
- timeOffset = 0;
66
+ _timeOffset = 0;
59
67
  cacheLimit = 24;
60
68
  prefetchBefore = 0;
61
69
  prefetchAfter = 0;
70
+ streamingLoad = true;
71
+ rangeRequests = true;
62
72
  onEvent;
63
73
  onWarning;
64
74
  currentRendererBackend = null;
@@ -66,6 +76,7 @@ class BaseVideoSubtitleRenderer {
66
76
  lastRenderInfo = null;
67
77
  loadedMetadataHandler = null;
68
78
  seekedHandler = null;
79
+ initPromise = null;
69
80
  // WebGPU renderer (optional, falls back to WebGL2 then Canvas2D)
70
81
  webgpuRenderer = null;
71
82
  useWebGPU = false;
@@ -74,6 +85,18 @@ class BaseVideoSubtitleRenderer {
74
85
  webgl2Renderer = null;
75
86
  useWebGL2 = false;
76
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;
77
100
  // Performance tracking
78
101
  perfStats = {
79
102
  framesRendered: 0,
@@ -88,8 +111,19 @@ class BaseVideoSubtitleRenderer {
88
111
  this.format = format;
89
112
  this.subUrl = options.subUrl;
90
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';
91
123
  this.onWebGPUFallback = options.onWebGPUFallback;
92
124
  this.onWebGL2Fallback = options.onWebGL2Fallback;
125
+ this.offscreenRenderOption = options.offscreenRender !== false;
126
+ this.frameAwareSync = options.frameAwareSync !== false;
93
127
  this.onEvent = options.onEvent;
94
128
  this.onWarning = options.onWarning;
95
129
  this.debug = Boolean(options.debug);
@@ -98,11 +132,51 @@ class BaseVideoSubtitleRenderer {
98
132
  this.cacheLimit = Math.max(0, Math.floor(options.cacheLimit ?? 24));
99
133
  this.prefetchBefore = Math.max(0, Math.floor(options.prefetchWindow?.before ?? 0));
100
134
  this.prefetchAfter = Math.max(0, Math.floor(options.prefetchWindow?.after ?? 0));
135
+ this.streamingLoad = options.streamingLoad !== false;
136
+ this.rangeRequests = options.rangeRequests !== false;
137
+ }
138
+ emitLoadProgress(format, progress, indexedCues) {
139
+ this.emitEvent({
140
+ type: 'load-progress',
141
+ format,
142
+ loadedBytes: progress.loaded,
143
+ totalBytes: progress.total,
144
+ ratio: progress.ratio,
145
+ strategy: progress.strategy,
146
+ rangeSupported: progress.rangeSupported,
147
+ indexedCues
148
+ });
149
+ }
150
+ emitIndexed(format, metadata, partial) {
151
+ this.emitEvent({ type: 'indexed', format, metadata, partial });
152
+ }
153
+ memoryProgress(byteLength) {
154
+ return {
155
+ loaded: byteLength,
156
+ total: byteLength,
157
+ ratio: 1,
158
+ rangeSupported: false,
159
+ strategy: 'memory'
160
+ };
101
161
  }
102
162
  /** Get current display settings */
103
163
  getDisplaySettings() {
104
164
  return { ...this.displaySettings };
105
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
+ }
106
180
  /** Get cache-related diagnostics for the active renderer session. */
107
181
  getCacheStats() {
108
182
  const state = this.getWorkerRendererState();
@@ -159,9 +233,16 @@ class BaseVideoSubtitleRenderer {
159
233
  minRenderTime: Math.round(minRenderTime * 100) / 100,
160
234
  lastRenderTime: Math.round(this.perfStats.lastRenderTime * 100) / 100,
161
235
  renderFps: this.perfStats.fpsTimestamps.length,
162
- currentIndex: this.lastRenderedIndex
236
+ currentIndex: this.lastRenderedIndex,
237
+ syncMode: this.getSynchronizationMode()
163
238
  };
164
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
+ }
165
246
  /** Set display settings and force re-render */
166
247
  setDisplaySettings(settings) {
167
248
  const nextSettings = {
@@ -181,67 +262,300 @@ class BaseVideoSubtitleRenderer {
181
262
  this.displaySettings = nextSettings;
182
263
  // Force re-render if settings changed
183
264
  if (changed) {
265
+ this.invalidatePresentation();
184
266
  this.lastRenderedIndex = -1;
185
267
  this.lastRenderedTime = -1;
268
+ this.renderPausedFrame();
186
269
  }
187
270
  }
188
271
  /** Reset display settings to defaults */
189
272
  resetDisplaySettings() {
190
273
  this.displaySettings = { ...DEFAULT_DISPLAY_SETTINGS };
274
+ this.invalidatePresentation();
191
275
  this.lastRenderedIndex = -1;
192
276
  this.lastRenderedTime = -1;
277
+ this.renderPausedFrame();
193
278
  }
194
279
  /** Start initialization. */
195
280
  startInit() {
196
- this.init().catch((error) => {
197
- this.emitEvent({ type: 'error', format: this.format, error: normalizeSubtitleError(error, { format: this.format }) });
281
+ this.initPromise = this.init();
282
+ this.initPromise.catch((error) => {
283
+ this.emitEvent({
284
+ type: 'error',
285
+ format: this.format,
286
+ error: normalizeSubtitleError(error, { format: this.format })
287
+ });
198
288
  });
199
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
+ }
200
330
  /** Initialize the renderer. */
201
331
  async init() {
202
332
  await initWasm();
203
333
  this.createCanvas();
204
334
  await new Promise((resolve) => setTimeout(resolve, 0));
205
335
  await this.loadSubtitles();
336
+ await this.ensureWorkerOffscreenAttached();
206
337
  this.startRenderLoop();
207
338
  }
208
- /** Create the canvas overlay positioned over the video. */
209
- createCanvas() {
210
- this.canvas = document.createElement('canvas');
211
- Object.assign(this.canvas.style, {
212
- position: 'absolute',
213
- pointerEvents: 'none',
214
- zIndex: '10'
215
- });
216
- const parent = this.video.parentElement;
217
- if (parent) {
218
- if (window.getComputedStyle(parent).position === 'static') {
219
- parent.style.position = 'relative';
220
- }
221
- 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;
222
406
  }
223
- // Try WebGPU first, then WebGL2, then Canvas2D
224
407
  if (isWebGPUSupported()) {
225
- this.initWebGPU();
408
+ void this.initWebGPU();
226
409
  }
227
410
  else if (isWebGL2Supported()) {
228
- this.initWebGL2();
411
+ void this.initWebGL2();
412
+ }
413
+ else if (this.shouldPreferWorkerOffscreen()) {
414
+ this.pendingWorkerOffscreen = true;
229
415
  }
230
416
  else {
231
417
  this.initCanvas2D();
232
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();
233
426
  this.updateCanvasSize();
234
427
  this.resizeObserver = new ResizeObserver(() => this.updateCanvasSize());
235
428
  this.resizeObserver.observe(this.video);
236
429
  this.loadedMetadataHandler = () => this.updateCanvasSize();
237
430
  this.seekedHandler = () => {
431
+ this.invalidatePresentation();
238
432
  this.lastRenderedIndex = -1;
239
433
  this.lastRenderedTime = -1;
434
+ this.lastSynchronizedTime = null;
435
+ this.offscreenFrameMetadata.clear();
240
436
  this.onSeek();
437
+ this.renderPausedFrame();
241
438
  };
242
439
  this.video.addEventListener('loadedmetadata', this.loadedMetadataHandler);
243
440
  this.video.addEventListener('seeked', this.seekedHandler);
244
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
+ }
245
559
  emitEvent(event) {
246
560
  this.onEvent?.(event);
247
561
  }
@@ -285,15 +599,16 @@ class BaseVideoSubtitleRenderer {
285
599
  this.lastRenderInfo = info;
286
600
  }
287
601
  /** Initialize WebGPU renderer. */
288
- async initWebGPU() {
602
+ async initWebGPU(allowBackendFallback = true) {
289
603
  try {
290
604
  this.webgpuRenderer = new WebGPURenderer();
291
605
  await this.webgpuRenderer.init();
292
606
  if (!this.canvas)
293
607
  return;
294
608
  const bounds = this.getVideoContentBounds();
295
- const width = Math.max(1, bounds.width * window.devicePixelRatio);
296
- 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);
297
612
  await this.webgpuRenderer.setCanvas(this.canvas, width, height);
298
613
  this.useWebGPU = true;
299
614
  this.emitRendererBackend('webgpu');
@@ -303,25 +618,25 @@ class BaseVideoSubtitleRenderer {
303
618
  this.webgpuRenderer = null;
304
619
  this.useWebGPU = false;
305
620
  this.onWebGPUFallback?.();
306
- // Try WebGL2 before Canvas2D
307
- if (isWebGL2Supported()) {
308
- this.initWebGL2();
621
+ if (allowBackendFallback && isWebGL2Supported()) {
622
+ void this.initWebGL2();
309
623
  }
310
624
  else {
311
- this.initCanvas2D();
625
+ this.preferWorkerOffscreenOrCanvas2D();
312
626
  }
313
627
  }
314
628
  }
315
629
  /** Initialize WebGL2 renderer. */
316
- async initWebGL2() {
630
+ async initWebGL2(allowBackendFallback = true) {
317
631
  try {
318
632
  this.webgl2Renderer = new WebGL2Renderer();
319
633
  await this.webgl2Renderer.init();
320
634
  if (!this.canvas)
321
635
  return;
322
636
  const bounds = this.getVideoContentBounds();
323
- const width = Math.max(1, bounds.width * window.devicePixelRatio);
324
- 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);
325
640
  await this.webgl2Renderer.setCanvas(this.canvas, width, height);
326
641
  this.useWebGL2 = true;
327
642
  this.emitRendererBackend('webgl2');
@@ -331,7 +646,12 @@ class BaseVideoSubtitleRenderer {
331
646
  this.webgl2Renderer = null;
332
647
  this.useWebGL2 = false;
333
648
  this.onWebGL2Fallback?.();
334
- this.initCanvas2D();
649
+ if (allowBackendFallback) {
650
+ this.preferWorkerOffscreenOrCanvas2D();
651
+ }
652
+ else {
653
+ this.initCanvas2D();
654
+ }
335
655
  }
336
656
  }
337
657
  /** Initialize Canvas2D renderer. */
@@ -345,6 +665,23 @@ class BaseVideoSubtitleRenderer {
345
665
  }
346
666
  /** Called when video seeks. */
347
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
+ }
348
685
  /** Calculate the actual video content bounds, accounting for letterboxing/pillarboxing */
349
686
  getVideoContentBounds() {
350
687
  const rect = this.video.getBoundingClientRect();
@@ -387,95 +724,181 @@ class BaseVideoSubtitleRenderer {
387
724
  const bounds = this.getVideoContentBounds();
388
725
  const width = bounds.width > 0 ? bounds.width : this.video.videoWidth || 1920;
389
726
  const height = bounds.height > 0 ? bounds.height : this.video.videoHeight || 1080;
390
- const pixelWidth = Math.max(1, width * window.devicePixelRatio);
391
- const pixelHeight = Math.max(1, height * window.devicePixelRatio);
392
- this.canvas.width = pixelWidth;
393
- 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;
394
732
  // Position canvas to match video content area
395
- this.canvas.style.left = `${bounds.x}px`;
396
- 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`;
397
736
  this.canvas.style.width = `${bounds.width}px`;
398
737
  this.canvas.style.height = `${bounds.height}px`;
399
- // Update GPU renderer size if active
400
- if (this.useWebGPU && this.webgpuRenderer) {
401
- 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
+ }
402
748
  }
403
- else if (this.useWebGL2 && this.webgl2Renderer) {
404
- 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
+ }
405
759
  }
406
760
  this.lastRenderedIndex = -1;
407
761
  this.lastRenderedTime = -1;
762
+ this.invalidatePresentation();
763
+ if (this.frameScheduler) {
764
+ this.renderPausedFrame();
765
+ }
408
766
  }
409
767
  /** Start the render loop. */
410
768
  startRenderLoop() {
411
- // Create reusable temp canvas for rendering
412
- this.tempCanvas = document.createElement('canvas');
413
- this.tempCtx = this.tempCanvas.getContext('2d');
414
- const render = () => {
415
- if (this.disposed)
416
- return;
417
- if (this.isLoaded) {
418
- const currentTime = this.video.currentTime + this.timeOffset;
419
- const currentIndex = this.findCurrentIndex(currentTime);
420
- // Only re-render if index changed
421
- if (currentIndex !== this.lastRenderedIndex) {
422
- const cacheHit = currentIndex >= 0 && this.getWorkerRendererState().frameCache.has(currentIndex);
423
- const startTime = performance.now();
424
- const outcome = this.renderFrame(currentTime, currentIndex);
425
- const endTime = performance.now();
426
- // Track performance
427
- const renderTime = endTime - startTime;
428
- this.perfStats.lastRenderTime = renderTime;
429
- this.perfStats.renderTimes.push(renderTime);
430
- // Keep only last 60 samples for rolling average
431
- if (this.perfStats.renderTimes.length > 60) {
432
- this.perfStats.renderTimes.shift();
433
- }
434
- this.perfStats.framesRendered++;
435
- this.perfStats.fpsTimestamps.push(endTime);
436
- // Check for frame drop (if render took longer than frame budget ~16.67ms for 60fps)
437
- const frameBudget = 16.67;
438
- if (renderTime > frameBudget) {
439
- this.perfStats.framesDropped++;
440
- }
441
- if (outcome.warning) {
442
- this.emitWarning(outcome.warning);
443
- }
444
- this.lastRenderedIndex = currentIndex;
445
- this.lastRenderedTime = currentTime;
446
- const cue = currentIndex >= 0 ? this.buildCueMetadata(currentIndex) : null;
447
- this.emitCueChange(cue);
448
- this.recordLastRenderInfo({
449
- time: currentTime,
450
- index: currentIndex,
451
- status: outcome.status,
452
- backend: this.currentRendererBackend,
453
- usingWorker: this.getCacheStats().usingWorker,
454
- cacheHit,
455
- renderDuration: Math.round(renderTime * 100) / 100,
456
- frameWidth: outcome.data?.width ?? null,
457
- frameHeight: outcome.data?.height ?? null,
458
- compositionCount: outcome.data?.compositionData.length ?? 0,
459
- cue,
460
- cache: this.getCacheStats(),
461
- capturedAt: endTime
462
- });
463
- this.emitEvent({ type: 'stats', stats: this.getStats() });
464
- if (currentIndex >= 0 && (this.prefetchBefore > 0 || this.prefetchAfter > 0)) {
465
- const prefetch = this.prefetchAroundTime;
466
- prefetch?.call(this, currentTime).catch(() => { });
467
- }
468
- }
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;
469
790
  }
470
- this.animationFrameId = requestAnimationFrame(render);
471
- };
472
- 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() });
473
893
  }
474
894
  /** Render a subtitle frame to the canvas. */
475
895
  renderFrame(time, index) {
476
896
  if (!this.canvas) {
477
897
  return { status: 'failed', data: null, warning: null };
478
898
  }
899
+ if (this.useWorkerOffscreen) {
900
+ return this.renderFrameWorkerOffscreen(time, index);
901
+ }
479
902
  // Get the data for this index
480
903
  const data = index >= 0 ? this.renderAtIndex(index) : undefined;
481
904
  // If data is undefined, it means async loading is in progress
@@ -489,7 +912,7 @@ class BaseVideoSubtitleRenderer {
489
912
  return { status: 'pending', data: null, warning: null };
490
913
  }
491
914
  }
492
- const renderIssue = index >= 0 ? this.getWorkerRendererState().renderIssues.get(index) ?? null : null;
915
+ const renderIssue = index >= 0 ? (this.getWorkerRendererState().renderIssues.get(index) ?? null) : null;
493
916
  const warning = warningFromRenderIssue(renderIssue, { format: this.format, cueIndex: index });
494
917
  // Use best available renderer
495
918
  if (this.useWebGPU && this.webgpuRenderer) {
@@ -512,6 +935,83 @@ class BaseVideoSubtitleRenderer {
512
935
  }
513
936
  return { status: 'rendered', data, warning: null };
514
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
+ }
515
1015
  computeLayout(data) {
516
1016
  if (!this.canvas) {
517
1017
  return { scaleX: 1, scaleY: 1, shiftX: 0, shiftY: 0, opacity: this.displaySettings.opacity };
@@ -533,9 +1033,7 @@ class BaseVideoSubtitleRenderer {
533
1033
  let frameShiftX = 0;
534
1034
  let frameShiftY = 0;
535
1035
  if (aspectMode !== 'stretch') {
536
- const uniformScale = aspectMode === 'cover'
537
- ? Math.max(stretchScaleX, stretchScaleY)
538
- : Math.min(stretchScaleX, stretchScaleY);
1036
+ const uniformScale = aspectMode === 'cover' ? Math.max(stretchScaleX, stretchScaleY) : Math.min(stretchScaleX, stretchScaleY);
539
1037
  baseScaleX = uniformScale;
540
1038
  baseScaleY = uniformScale;
541
1039
  frameShiftX = (this.canvas.width - safeDataWidth * uniformScale) / 2;
@@ -643,10 +1141,9 @@ class BaseVideoSubtitleRenderer {
643
1141
  /** Dispose of all resources. */
644
1142
  dispose() {
645
1143
  this.disposed = true;
646
- if (this.animationFrameId !== null) {
647
- cancelAnimationFrame(this.animationFrameId);
648
- this.animationFrameId = null;
649
- }
1144
+ this.invalidatePresentation();
1145
+ this.frameScheduler?.stop();
1146
+ this.frameScheduler = null;
650
1147
  this.resizeObserver?.disconnect();
651
1148
  this.resizeObserver = null;
652
1149
  if (this.loadedMetadataHandler) {
@@ -666,7 +1163,11 @@ class BaseVideoSubtitleRenderer {
666
1163
  this.webgl2Renderer.destroy();
667
1164
  this.webgl2Renderer = null;
668
1165
  }
669
- 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();
670
1171
  this.canvas = null;
671
1172
  this.ctx = null;
672
1173
  this.tempCanvas = null;
@@ -675,8 +1176,13 @@ class BaseVideoSubtitleRenderer {
675
1176
  this.currentCueMetadata = null;
676
1177
  this.parserMetadata = null;
677
1178
  this.lastRenderInfo = null;
1179
+ this.offscreenFrameMetadata.clear();
678
1180
  this.useWebGPU = false;
679
1181
  this.useWebGL2 = false;
1182
+ this.useWorkerOffscreen = false;
1183
+ this.pendingWorkerOffscreen = false;
1184
+ this.offscreenTransferPending = false;
1185
+ this.offscreenAttachPromise = null;
680
1186
  }
681
1187
  }
682
1188
  /**
@@ -686,6 +1192,8 @@ class BaseVideoSubtitleRenderer {
686
1192
  export class PgsRenderer extends BaseVideoSubtitleRenderer {
687
1193
  pgsParser = null;
688
1194
  state = createWorkerState();
1195
+ streamOperationQueue = Promise.resolve();
1196
+ streamGeneration = 0;
689
1197
  onLoading;
690
1198
  onLoaded;
691
1199
  onError;
@@ -701,77 +1209,346 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
701
1209
  try {
702
1210
  this.emitEvent({ type: 'loading', format: 'pgs' });
703
1211
  this.onLoading?.();
704
- let arrayBuffer;
705
1212
  if (this.subContent) {
706
- arrayBuffer = this.subContent;
1213
+ const data = new Uint8Array(this.subContent);
1214
+ this.emitLoadProgress('pgs', this.memoryProgress(data.byteLength), 0);
1215
+ await this.loadPgsBuffer(data, true);
1216
+ this.onLoaded?.();
1217
+ return;
1218
+ }
1219
+ if (!this.subUrl) {
1220
+ await this.beginPgsPushSession();
1221
+ this.onLoaded?.();
1222
+ return;
1223
+ }
1224
+ if (!this.streamingLoad) {
1225
+ const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(this.subUrl, {
1226
+ preferRange: this.rangeRequests,
1227
+ onProgress: (progress) => this.emitLoadProgress('pgs', progress, this.state.timestamps.length)
1228
+ });
1229
+ this.emitLoadProgress('pgs', { loaded: data.byteLength, total: total ?? data.byteLength, ratio: 1, rangeSupported, strategy }, 0);
1230
+ await this.loadPgsBuffer(data, false);
1231
+ this.onLoaded?.();
1232
+ return;
1233
+ }
1234
+ await this.loadPgsStreaming(this.subUrl);
1235
+ this.onLoaded?.();
1236
+ }
1237
+ catch (error) {
1238
+ const resolvedError = normalizeSubtitleError(error, { format: 'pgs' });
1239
+ this.emitEvent({ type: 'error', format: 'pgs', error: resolvedError });
1240
+ this.onError?.(resolvedError);
1241
+ }
1242
+ }
1243
+ applyPgsIndexState(metadata, timestamps, partial, usingWorker) {
1244
+ this.state.metadata = metadata;
1245
+ this.state.timestamps = timestamps;
1246
+ this.setParserMetadata(metadata);
1247
+ this.emitIndexed('pgs', metadata, partial);
1248
+ if (!this.isLoaded && timestamps.length > 0) {
1249
+ this.isLoaded = true;
1250
+ if (usingWorker) {
1251
+ this.state.workerReady = true;
1252
+ this.emitWorkerState(true, true, this.state.sessionId);
1253
+ }
1254
+ }
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;
707
1272
  }
708
- else if (this.subUrl) {
709
- const response = await fetch(this.subUrl);
710
- if (!response.ok)
711
- throw new Error(`Failed to fetch subtitle: ${response.status}`);
712
- arrayBuffer = await response.arrayBuffer();
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);
713
1359
  }
714
1360
  else {
715
- throw new Error('No subtitle content or URL provided');
716
- }
717
- let data = new Uint8Array(arrayBuffer);
718
- if (this.state.useWorker) {
719
- try {
720
- this.state.sessionId = createWorkerSessionId();
721
- await getOrCreateWorker();
722
- this.emitWorkerState(true, false, this.state.sessionId);
723
- const transferableData = createTransferableBuffer(data, Boolean(this.subContent));
724
- const loadResponse = await sendToWorker({
725
- type: 'loadPgs',
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
+ }
1400
+ async loadPgsBuffer(data, preserveSource) {
1401
+ if (this.state.useWorker) {
1402
+ try {
1403
+ this.state.sessionId = createWorkerSessionId();
1404
+ await getOrCreateWorker();
1405
+ this.emitWorkerState(true, false, this.state.sessionId);
1406
+ const transferableData = createTransferableBuffer(data, preserveSource);
1407
+ const loadResponse = await sendToWorker({
1408
+ type: 'loadPgs',
1409
+ sessionId: this.state.sessionId,
1410
+ data: transferableData
1411
+ });
1412
+ if (loadResponse.type === 'pgsLoaded') {
1413
+ this.state.workerReady = true;
1414
+ this.state.metadata = loadResponse.metadata;
1415
+ this.state.timestamps = loadResponse.timestamps;
1416
+ this.isLoaded = true;
1417
+ this.setParserMetadata(loadResponse.metadata);
1418
+ this.emitIndexed('pgs', loadResponse.metadata, false);
1419
+ this.emitWorkerState(true, true, this.state.sessionId);
1420
+ return;
1421
+ }
1422
+ else if (loadResponse.type === 'error') {
1423
+ throw new Error(loadResponse.message);
1424
+ }
1425
+ }
1426
+ catch (workerError) {
1427
+ this.state.useWorker = false;
1428
+ this.emitWorkerState(false, false, this.state.sessionId, true);
1429
+ this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'PGS worker initialization failed, falling back to main-thread rendering.', {
1430
+ format: 'pgs',
1431
+ details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
1432
+ }));
1433
+ }
1434
+ }
1435
+ await this.loadOnMainThread(data);
1436
+ }
1437
+ async loadPgsStreaming(url) {
1438
+ let usedWorker = false;
1439
+ let indexedOnce = false;
1440
+ if (this.state.useWorker) {
1441
+ try {
1442
+ this.state.sessionId = createWorkerSessionId();
1443
+ await getOrCreateWorker();
1444
+ this.emitWorkerState(true, false, this.state.sessionId);
1445
+ const begin = await sendToWorker({ type: 'beginPgs', sessionId: this.state.sessionId });
1446
+ if (begin.type === 'error')
1447
+ throw new Error(begin.message);
1448
+ usedWorker = true;
1449
+ }
1450
+ catch (workerError) {
1451
+ this.state.useWorker = false;
1452
+ usedWorker = false;
1453
+ this.emitWorkerState(false, false, this.state.sessionId, true);
1454
+ this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'PGS worker initialization failed, falling back to main-thread rendering.', {
1455
+ format: 'pgs',
1456
+ details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
1457
+ }));
1458
+ }
1459
+ }
1460
+ if (!usedWorker) {
1461
+ await this.yieldToMain();
1462
+ this.pgsParser = new PgsParser({ debug: this.debug, onWarning: (warning) => this.emitWarning(warning) });
1463
+ this.pgsParser.reset();
1464
+ }
1465
+ try {
1466
+ const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(url, {
1467
+ preferRange: this.rangeRequests,
1468
+ onProgress: (progress) => this.emitLoadProgress('pgs', progress, this.state.timestamps.length)
1469
+ }, async (chunk, progress) => {
1470
+ if (chunk.byteLength === 0)
1471
+ return;
1472
+ if (usedWorker && this.state.sessionId) {
1473
+ const transferable = createTransferableBuffer(chunk, true);
1474
+ const response = await sendToWorker({
1475
+ type: 'appendPgs',
726
1476
  sessionId: this.state.sessionId,
727
- data: transferableData
1477
+ data: transferable
728
1478
  });
729
- if (loadResponse.type === 'pgsLoaded') {
730
- this.state.workerReady = true;
731
- this.state.metadata = loadResponse.metadata;
732
- this.state.timestamps = loadResponse.timestamps;
733
- this.isLoaded = true;
734
- this.setParserMetadata(loadResponse.metadata);
735
- this.emitWorkerState(true, true, this.state.sessionId);
736
- this.onLoaded?.();
737
- return; // Success, don't fall through to main thread
1479
+ if (response.type === 'pgsProgress') {
1480
+ if (response.added > 0 || !indexedOnce) {
1481
+ this.applyPgsIndexState(response.metadata, response.timestamps, true, true);
1482
+ indexedOnce = true;
1483
+ }
1484
+ else {
1485
+ this.state.timestamps = response.timestamps;
1486
+ this.state.metadata = response.metadata;
1487
+ }
738
1488
  }
739
- else if (loadResponse.type === 'error') {
740
- throw new Error(loadResponse.message);
1489
+ else if (response.type === 'error') {
1490
+ throw new Error(response.message);
741
1491
  }
742
1492
  }
743
- catch (workerError) {
744
- this.state.useWorker = false;
745
- this.emitWorkerState(false, false, this.state.sessionId, true);
746
- this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'PGS worker initialization failed, falling back to main-thread rendering.', {
747
- format: 'pgs',
748
- details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
749
- }));
750
- if (!this.subContent && this.subUrl && data.byteLength === 0) {
751
- const response = await fetch(this.subUrl);
752
- if (!response.ok)
753
- throw new Error(`Failed to fetch subtitle: ${response.status}`);
754
- data = new Uint8Array(await response.arrayBuffer());
1493
+ else if (this.pgsParser) {
1494
+ const added = this.pgsParser.feed(chunk);
1495
+ if (added > 0 || !indexedOnce) {
1496
+ const metadata = this.pgsParser.getMetadata();
1497
+ this.applyPgsIndexState(metadata, this.pgsParser.getTimestamps(), true, false);
1498
+ indexedOnce = true;
755
1499
  }
756
1500
  }
1501
+ this.emitLoadProgress('pgs', progress, this.state.timestamps.length);
1502
+ });
1503
+ if (usedWorker && this.state.sessionId) {
1504
+ const finish = await sendToWorker({ type: 'finishPgs', sessionId: this.state.sessionId });
1505
+ if (finish.type === 'pgsProgress') {
1506
+ this.applyPgsIndexState(finish.metadata, finish.timestamps, false, true);
1507
+ this.state.workerReady = true;
1508
+ this.isLoaded = true;
1509
+ this.emitWorkerState(true, true, this.state.sessionId);
1510
+ }
1511
+ else if (finish.type === 'error') {
1512
+ throw new Error(finish.message);
1513
+ }
757
1514
  }
758
- // Main thread fallback - use idle callback to avoid blocking UI
759
- await this.loadOnMainThread(data);
760
- this.onLoaded?.();
1515
+ else if (this.pgsParser) {
1516
+ this.pgsParser.finishFeed();
1517
+ const metadata = this.pgsParser.getMetadata();
1518
+ this.state.timestamps = this.pgsParser.getTimestamps();
1519
+ this.state.metadata = metadata;
1520
+ this.isLoaded = true;
1521
+ this.setParserMetadata(metadata);
1522
+ this.emitIndexed('pgs', metadata, false);
1523
+ if (metadata.cueCount === 0) {
1524
+ this.state.renderIssues.set(-1, 'INVALID_SUBTITLE_DATA');
1525
+ }
1526
+ }
1527
+ this.emitLoadProgress('pgs', {
1528
+ loaded: data.byteLength,
1529
+ total: total ?? data.byteLength,
1530
+ ratio: 1,
1531
+ rangeSupported,
1532
+ strategy: strategy
1533
+ }, this.state.timestamps.length);
761
1534
  }
762
1535
  catch (error) {
763
- const resolvedError = normalizeSubtitleError(error, { format: 'pgs' });
764
- this.emitEvent({ type: 'error', format: 'pgs', error: resolvedError });
765
- this.onError?.(resolvedError);
1536
+ if (usedWorker) {
1537
+ this.state.useWorker = false;
1538
+ this.emitWorkerState(false, false, this.state.sessionId, true);
1539
+ }
1540
+ this.emitWarning(createSubtitleWarning('RANGE_FALLBACK', 'Progressive PGS load failed; retrying with a full buffer fetch.', {
1541
+ format: 'pgs',
1542
+ details: { reason: error instanceof Error ? error.message : String(error) }
1543
+ }));
1544
+ const { data } = await fetchSubtitleAsset(url, { preferRange: this.rangeRequests });
1545
+ await this.loadPgsBuffer(data, false);
766
1546
  }
767
1547
  }
768
1548
  async loadOnMainThread(data) {
769
- // Yield to browser before heavy parsing
770
1549
  await this.yieldToMain();
771
1550
  this.pgsParser = new PgsParser({ debug: this.debug, onWarning: (warning) => this.emitWarning(warning) });
772
- // Parse in a microtask to allow UI to update
773
1551
  await new Promise((resolve) => {
774
- // Use requestIdleCallback if available, otherwise setTimeout
775
1552
  const scheduleTask = typeof requestIdleCallback !== 'undefined'
776
1553
  ? (cb) => requestIdleCallback(() => cb(), { timeout: 1000 })
777
1554
  : (cb) => setTimeout(cb, 0);
@@ -781,6 +1558,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
781
1558
  this.state.metadata = this.pgsParser.getMetadata();
782
1559
  this.isLoaded = true;
783
1560
  this.setParserMetadata(this.state.metadata);
1561
+ this.emitIndexed('pgs', this.state.metadata, false);
784
1562
  if (count === 0) {
785
1563
  this.state.renderIssues.set(-1, 'INVALID_SUBTITLE_DATA');
786
1564
  }
@@ -817,6 +1595,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
817
1595
  }
818
1596
  if (this.state.useWorker && this.state.workerReady) {
819
1597
  if (!this.state.pendingRenders.has(index)) {
1598
+ const generation = this.streamGeneration;
820
1599
  const renderTask = sendToWorker({
821
1600
  type: 'renderPgsAtIndex',
822
1601
  sessionId: this.state.sessionId,
@@ -834,15 +1613,14 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
834
1613
  this.state.pendingRenders.set(index, renderPromise);
835
1614
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
836
1615
  renderTask.then(({ frame, renderIssue }) => {
1616
+ if (generation !== this.streamGeneration || this.disposed)
1617
+ return;
837
1618
  setCachedFrame(this.state, index, frame, renderIssue);
838
1619
  this.state.pendingRenders.delete(index);
839
1620
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
840
- // Force re-render on next frame by resetting lastRenderedIndex
841
- if (this.findCurrentIndex(this.video.currentTime + this.timeOffset) === index) {
842
- this.lastRenderedIndex = -1;
843
- }
844
1621
  });
845
1622
  }
1623
+ this.watchPendingRender(index, this.state.pendingRenders.get(index));
846
1624
  // Return undefined to indicate async loading in progress
847
1625
  return undefined;
848
1626
  }
@@ -861,6 +1639,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
861
1639
  const startTime = this.state.timestamps[index];
862
1640
  const endTime = this.state.timestamps[index + 1] ?? startTime + 5000;
863
1641
  const frame = this.state.frameCache.get(index) ?? null;
1642
+ const offscreenFrame = this.getOffscreenFrameMetadata(index);
864
1643
  return {
865
1644
  index,
866
1645
  format: 'pgs',
@@ -869,8 +1648,8 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
869
1648
  duration: Math.max(0, endTime - startTime),
870
1649
  screenWidth: metadata.screenWidth,
871
1650
  screenHeight: metadata.screenHeight,
872
- bounds: frame ? getSubtitleBounds(frame) : null,
873
- compositionCount: frame?.compositionData.length ?? 0
1651
+ bounds: frame ? getSubtitleBounds(frame) : (offscreenFrame?.bounds ?? null),
1652
+ compositionCount: frame?.compositionData.length ?? offscreenFrame?.compositionCount ?? 0
874
1653
  };
875
1654
  }
876
1655
  isPendingRender(index) {
@@ -891,15 +1670,18 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
891
1670
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
892
1671
  }
893
1672
  clearFrameCache() {
1673
+ this.invalidatePresentation();
894
1674
  this.state.frameCache.clear();
895
1675
  this.state.renderIssues.clear();
896
1676
  this.state.pendingRenders.clear();
1677
+ this.clearOffscreenFrameMetadata();
897
1678
  this.lastRenderedIndex = -1;
898
1679
  if (this.state.useWorker && this.state.workerReady) {
899
1680
  sendToWorker({ type: 'clearPgsCache', sessionId: this.state.sessionId }).catch(() => { });
900
1681
  }
901
1682
  this.pgsParser?.clearCache();
902
1683
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
1684
+ this.renderPausedFrame();
903
1685
  }
904
1686
  async prefetchRange(startIndex, endIndex) {
905
1687
  const safeStart = Math.max(0, Math.min(startIndex, endIndex));
@@ -931,6 +1713,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
931
1713
  };
932
1714
  }
933
1715
  dispose() {
1716
+ this.streamGeneration++;
934
1717
  super.dispose();
935
1718
  this.state.frameCache.clear();
936
1719
  this.state.renderIssues.clear();
@@ -962,9 +1745,9 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
962
1745
  pendingIndexLookup = null;
963
1746
  constructor(options) {
964
1747
  super(options, 'vobsub');
965
- this.idxUrl = options.idxUrl || (options.subUrl && /\.sub$/i.test(options.subUrl)
966
- ? options.subUrl.replace(/\.sub$/i, '.idx')
967
- : undefined);
1748
+ this.idxUrl =
1749
+ options.idxUrl ||
1750
+ (options.subUrl && /\.sub$/i.test(options.subUrl) ? options.subUrl.replace(/\.sub$/i, '.idx') : undefined);
968
1751
  this.idxContent = options.idxContent;
969
1752
  this.fileName = options.fileName;
970
1753
  this.onLoading = options.onLoading;
@@ -977,125 +1760,251 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
977
1760
  try {
978
1761
  this.emitEvent({ type: 'loading', format: 'vobsub' });
979
1762
  this.onLoading?.();
980
- let subArrayBuffer;
981
- let idxData;
982
- const useMksSource = !this.idxContent && !this.idxUrl && isMksSource({
983
- subData: this.subContent,
984
- fileName: this.fileName,
985
- subUrl: this.subUrl
986
- });
987
- // Resolve SUB content
988
- if (this.subContent) {
989
- subArrayBuffer = this.subContent;
1763
+ const useMksSource = !this.idxContent &&
1764
+ !this.idxUrl &&
1765
+ isMksSource({
1766
+ subData: this.subContent,
1767
+ fileName: this.fileName,
1768
+ subUrl: this.subUrl
1769
+ });
1770
+ if (this.subContent && (useMksSource || this.idxContent)) {
1771
+ const subData = new Uint8Array(this.subContent);
1772
+ this.emitLoadProgress('vobsub', this.memoryProgress(subData.byteLength), 0);
1773
+ await this.loadVobSubBuffer(subData, this.idxContent, useMksSource, true);
1774
+ this.onLoaded?.();
1775
+ return;
990
1776
  }
991
- // Resolve IDX content
992
- if (this.idxContent) {
993
- idxData = this.idxContent;
1777
+ if (useMksSource) {
1778
+ await this.loadVobSubMksStreaming();
1779
+ this.onLoaded?.();
1780
+ return;
994
1781
  }
995
- // Fetch missing parts
996
- const promises = [];
997
- if (!subArrayBuffer) {
998
- if (!this.subUrl) {
999
- throw createSubtitleDiagnosticError('MISSING_INPUT', 'No SUB content or URL provided.', { format: 'vobsub' });
1782
+ await this.loadVobSubIdxSubStreaming();
1783
+ this.onLoaded?.();
1784
+ }
1785
+ catch (error) {
1786
+ const resolvedError = normalizeSubtitleError(error, { format: 'vobsub' });
1787
+ this.emitEvent({ type: 'error', format: 'vobsub', error: resolvedError });
1788
+ this.onError?.(resolvedError);
1789
+ }
1790
+ }
1791
+ applyVobSubIndexState(metadata, timestamps, partial, renderable, usingWorker) {
1792
+ this.state.metadata = metadata;
1793
+ this.state.timestamps = timestamps;
1794
+ this.setParserMetadata(metadata);
1795
+ this.emitIndexed('vobsub', metadata, partial);
1796
+ if (renderable && !this.isLoaded && timestamps.length > 0) {
1797
+ this.isLoaded = true;
1798
+ if (usingWorker) {
1799
+ this.state.workerReady = true;
1800
+ this.emitWorkerState(true, true, this.state.sessionId);
1801
+ }
1802
+ }
1803
+ }
1804
+ async ensureVobSubWorkerSession() {
1805
+ if (!this.state.useWorker)
1806
+ return false;
1807
+ try {
1808
+ this.state.sessionId = createWorkerSessionId();
1809
+ await getOrCreateWorker();
1810
+ this.emitWorkerState(true, false, this.state.sessionId);
1811
+ return true;
1812
+ }
1813
+ catch (workerError) {
1814
+ this.state.useWorker = false;
1815
+ this.emitWorkerState(false, false, this.state.sessionId, true);
1816
+ this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'VobSub worker initialization failed, falling back to main-thread rendering.', {
1817
+ format: 'vobsub',
1818
+ details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
1819
+ }));
1820
+ return false;
1821
+ }
1822
+ }
1823
+ async loadVobSubBuffer(subData, idxData, useMksSource, preserveSource) {
1824
+ if (await this.ensureVobSubWorkerSession()) {
1825
+ try {
1826
+ const transferableSubData = createTransferableBuffer(subData, preserveSource);
1827
+ const loadResponse = await sendToWorker(useMksSource
1828
+ ? {
1829
+ type: 'loadVobSubMks',
1830
+ sessionId: this.state.sessionId,
1831
+ subData: transferableSubData
1832
+ }
1833
+ : {
1834
+ type: 'loadVobSub',
1835
+ sessionId: this.state.sessionId,
1836
+ idxContent: idxData,
1837
+ subData: transferableSubData
1838
+ });
1839
+ if (loadResponse.type === 'vobSubLoaded') {
1840
+ if (!useMksSource && idxData && loadResponse.count === 0) {
1841
+ throw createSubtitleDiagnosticError('BAD_IDX', 'IDX metadata did not yield any subtitle timestamps.', {
1842
+ format: 'vobsub'
1843
+ });
1844
+ }
1845
+ this.state.workerReady = true;
1846
+ this.state.metadata = loadResponse.metadata;
1847
+ this.state.timestamps = loadResponse.timestamps;
1848
+ this.isLoaded = true;
1849
+ this.setParserMetadata(loadResponse.metadata);
1850
+ this.emitIndexed('vobsub', loadResponse.metadata, false);
1851
+ this.emitWorkerState(true, true, this.state.sessionId);
1852
+ return;
1853
+ }
1854
+ else if (loadResponse.type === 'error') {
1855
+ throw new Error(loadResponse.message);
1000
1856
  }
1001
- promises.push(fetch(this.subUrl)
1002
- .then((r) => {
1003
- if (!r.ok)
1004
- throw new Error(`Failed to fetch .sub file: ${r.status}`);
1005
- return r.arrayBuffer();
1006
- })
1007
- .then((b) => {
1008
- subArrayBuffer = b;
1009
- }));
1010
1857
  }
1011
- if (!useMksSource && !idxData) {
1012
- if (!this.idxUrl) {
1013
- throw createSubtitleDiagnosticError('MISSING_INPUT', 'No IDX content or URL provided.', { format: 'vobsub' });
1858
+ catch (workerError) {
1859
+ if (workerError instanceof SubtitleDiagnosticError && workerError.code === 'BAD_IDX') {
1860
+ throw workerError;
1014
1861
  }
1015
- promises.push(fetch(this.idxUrl)
1016
- .then((r) => {
1017
- if (!r.ok)
1018
- throw new Error(`Failed to fetch .idx file: ${r.status}`);
1019
- return r.text();
1020
- })
1021
- .then((t) => {
1022
- idxData = t;
1862
+ this.state.useWorker = false;
1863
+ this.emitWorkerState(false, false, this.state.sessionId, true);
1864
+ this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'VobSub worker load failed, falling back to main-thread rendering.', {
1865
+ format: 'vobsub',
1866
+ details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
1023
1867
  }));
1024
1868
  }
1025
- if (promises.length > 0) {
1026
- await Promise.all(promises);
1027
- }
1028
- if (!subArrayBuffer || (!useMksSource && !idxData)) {
1029
- throw createSubtitleDiagnosticError('MISSING_INPUT', 'Failed to load VobSub data.', { format: 'vobsub' });
1030
- }
1031
- let subData = new Uint8Array(subArrayBuffer);
1032
- if (this.state.useWorker) {
1033
- try {
1034
- this.state.sessionId = createWorkerSessionId();
1035
- await getOrCreateWorker();
1036
- this.emitWorkerState(true, false, this.state.sessionId);
1037
- const transferableSubData = createTransferableBuffer(subData, Boolean(this.subContent));
1038
- const loadResponse = await sendToWorker(useMksSource
1039
- ? {
1040
- type: 'loadVobSubMks',
1041
- sessionId: this.state.sessionId,
1042
- subData: transferableSubData
1043
- }
1044
- : {
1045
- type: 'loadVobSub',
1046
- sessionId: this.state.sessionId,
1047
- idxContent: idxData,
1048
- subData: transferableSubData
1869
+ }
1870
+ await this.loadOnMainThread(subData, idxData, useMksSource);
1871
+ }
1872
+ async loadVobSubMksStreaming() {
1873
+ if (this.subContent) {
1874
+ await this.loadVobSubBuffer(new Uint8Array(this.subContent), undefined, true, true);
1875
+ return;
1876
+ }
1877
+ if (!this.subUrl) {
1878
+ throw createSubtitleDiagnosticError('MISSING_INPUT', 'No SUB content or URL provided.', { format: 'vobsub' });
1879
+ }
1880
+ const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(this.subUrl, {
1881
+ preferRange: this.rangeRequests && this.streamingLoad,
1882
+ onProgress: (progress) => this.emitLoadProgress('vobsub', progress, this.state.timestamps.length)
1883
+ });
1884
+ this.emitLoadProgress('vobsub', {
1885
+ loaded: data.byteLength,
1886
+ total: total ?? data.byteLength,
1887
+ ratio: 1,
1888
+ rangeSupported,
1889
+ strategy
1890
+ }, 0);
1891
+ await this.loadVobSubBuffer(data, undefined, true, false);
1892
+ }
1893
+ async loadVobSubIdxSubStreaming() {
1894
+ let idxData = this.idxContent;
1895
+ if (!idxData) {
1896
+ if (!this.idxUrl) {
1897
+ throw createSubtitleDiagnosticError('MISSING_INPUT', 'No IDX content or URL provided.', { format: 'vobsub' });
1898
+ }
1899
+ idxData = await fetchSubtitleText(this.idxUrl, {
1900
+ onProgress: (progress) => this.emitLoadProgress('vobsub', progress, 0)
1901
+ });
1902
+ }
1903
+ let usedWorker = await this.ensureVobSubWorkerSession();
1904
+ if (usedWorker && this.state.sessionId) {
1905
+ try {
1906
+ const idxResponse = await sendToWorker({
1907
+ type: 'loadVobSubIdx',
1908
+ sessionId: this.state.sessionId,
1909
+ idxContent: idxData
1910
+ });
1911
+ if (idxResponse.type === 'vobSubProgress') {
1912
+ if (idxResponse.count === 0) {
1913
+ throw createSubtitleDiagnosticError('BAD_IDX', 'IDX metadata did not yield any subtitle timestamps.', {
1914
+ format: 'vobsub'
1049
1915
  });
1050
- if (loadResponse.type === 'vobSubLoaded') {
1051
- if (!useMksSource && idxData && loadResponse.count === 0) {
1052
- throw createSubtitleDiagnosticError('BAD_IDX', 'IDX metadata did not yield any subtitle timestamps.', {
1053
- format: 'vobsub'
1054
- });
1055
- }
1056
- this.state.workerReady = true;
1057
- this.state.metadata = loadResponse.metadata;
1058
- this.state.timestamps = loadResponse.timestamps;
1059
- this.isLoaded = true;
1060
- this.setParserMetadata(loadResponse.metadata);
1061
- this.emitWorkerState(true, true, this.state.sessionId);
1062
- this.onLoaded?.();
1063
- return; // Success, don't fall through to main thread
1064
- }
1065
- else if (loadResponse.type === 'error') {
1066
- throw new Error(loadResponse.message);
1067
1916
  }
1917
+ this.applyVobSubIndexState(idxResponse.metadata, idxResponse.timestamps, true, false, true);
1918
+ this.state.workerReady = true;
1919
+ this.emitWorkerState(true, true, this.state.sessionId);
1068
1920
  }
1069
- catch (workerError) {
1070
- this.state.useWorker = false;
1071
- this.emitWorkerState(false, false, this.state.sessionId, true);
1072
- this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'VobSub worker initialization failed, falling back to main-thread rendering.', {
1073
- format: 'vobsub',
1074
- details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
1075
- }));
1076
- if (!this.subContent && this.subUrl && subData.byteLength === 0) {
1077
- const response = await fetch(this.subUrl);
1078
- if (!response.ok)
1079
- throw new Error(`Failed to fetch .sub file: ${response.status}`);
1080
- subData = new Uint8Array(await response.arrayBuffer());
1081
- }
1921
+ else if (idxResponse.type === 'error') {
1922
+ throw new Error(idxResponse.message);
1082
1923
  }
1083
1924
  }
1084
- // Main thread fallback
1085
- await this.loadOnMainThread(subData, idxData, useMksSource);
1086
- this.onLoaded?.();
1925
+ catch (error) {
1926
+ if (error instanceof SubtitleDiagnosticError && error.code === 'BAD_IDX') {
1927
+ throw error;
1928
+ }
1929
+ usedWorker = false;
1930
+ this.state.useWorker = false;
1931
+ this.emitWorkerState(false, false, this.state.sessionId, true);
1932
+ this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'VobSub worker IDX load failed, falling back to main-thread rendering.', {
1933
+ format: 'vobsub',
1934
+ details: { reason: error instanceof Error ? error.message : String(error) }
1935
+ }));
1936
+ }
1087
1937
  }
1088
- catch (error) {
1089
- const resolvedError = normalizeSubtitleError(error, { format: 'vobsub' });
1090
- this.emitEvent({ type: 'error', format: 'vobsub', error: resolvedError });
1091
- this.onError?.(resolvedError);
1938
+ if (!usedWorker) {
1939
+ await this.yieldToMain();
1940
+ this.vobsubParser = new VobSubParserLowLevel({
1941
+ debug: this.debug,
1942
+ onWarning: (warning) => this.emitWarning(warning)
1943
+ });
1944
+ this.vobsubParser.loadFromIdx(idxData);
1945
+ this.applyVobSubIndexState(this.vobsubParser.getMetadata(), this.vobsubParser.getTimestamps(), true, false, false);
1946
+ }
1947
+ let subData;
1948
+ if (this.subContent) {
1949
+ subData = new Uint8Array(this.subContent);
1950
+ this.emitLoadProgress('vobsub', this.memoryProgress(subData.byteLength), this.state.timestamps.length);
1951
+ }
1952
+ else {
1953
+ if (!this.subUrl) {
1954
+ throw createSubtitleDiagnosticError('MISSING_INPUT', 'No SUB content or URL provided.', { format: 'vobsub' });
1955
+ }
1956
+ const fetched = await fetchSubtitleAsset(this.subUrl, {
1957
+ preferRange: this.rangeRequests && this.streamingLoad,
1958
+ onProgress: (progress) => this.emitLoadProgress('vobsub', progress, this.state.timestamps.length)
1959
+ });
1960
+ subData = fetched.data;
1961
+ this.emitLoadProgress('vobsub', {
1962
+ loaded: subData.byteLength,
1963
+ total: fetched.total ?? subData.byteLength,
1964
+ ratio: 1,
1965
+ rangeSupported: fetched.rangeSupported,
1966
+ strategy: fetched.strategy
1967
+ }, this.state.timestamps.length);
1968
+ }
1969
+ if (usedWorker && this.state.sessionId) {
1970
+ const transferable = createTransferableBuffer(subData, Boolean(this.subContent));
1971
+ const attachResponse = await sendToWorker({
1972
+ type: 'attachVobSubData',
1973
+ sessionId: this.state.sessionId,
1974
+ subData: transferable
1975
+ });
1976
+ if (attachResponse.type === 'vobSubProgress') {
1977
+ this.applyVobSubIndexState(attachResponse.metadata, attachResponse.timestamps, false, true, true);
1978
+ this.state.workerReady = true;
1979
+ this.isLoaded = true;
1980
+ this.emitWorkerState(true, true, this.state.sessionId);
1981
+ return;
1982
+ }
1983
+ else if (attachResponse.type === 'error') {
1984
+ throw new Error(attachResponse.message);
1985
+ }
1986
+ }
1987
+ if (!this.vobsubParser) {
1988
+ this.vobsubParser = new VobSubParserLowLevel({
1989
+ debug: this.debug,
1990
+ onWarning: (warning) => this.emitWarning(warning)
1991
+ });
1992
+ this.vobsubParser.loadFromIdx(idxData);
1092
1993
  }
1994
+ this.vobsubParser.attachSubData(subData);
1995
+ const metadata = this.vobsubParser.getMetadata();
1996
+ this.state.timestamps = this.vobsubParser.getTimestamps();
1997
+ this.state.metadata = metadata;
1998
+ this.isLoaded = true;
1999
+ this.setParserMetadata(metadata);
2000
+ this.emitIndexed('vobsub', metadata, false);
1093
2001
  }
1094
2002
  async loadOnMainThread(subData, idxData, useMksSource = false) {
1095
- // Yield to browser before heavy parsing
1096
2003
  await this.yieldToMain();
1097
- this.vobsubParser = new VobSubParserLowLevel({ debug: this.debug, onWarning: (warning) => this.emitWarning(warning) });
1098
- // Parse in a microtask to allow UI to update
2004
+ this.vobsubParser = new VobSubParserLowLevel({
2005
+ debug: this.debug,
2006
+ onWarning: (warning) => this.emitWarning(warning)
2007
+ });
1099
2008
  await new Promise((resolve) => {
1100
2009
  const scheduleTask = typeof requestIdleCallback !== 'undefined'
1101
2010
  ? (cb) => requestIdleCallback(() => cb(), { timeout: 1000 })
@@ -1114,6 +2023,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1114
2023
  this.state.metadata = this.vobsubParser.getMetadata();
1115
2024
  this.isLoaded = true;
1116
2025
  this.setParserMetadata(this.state.metadata);
2026
+ this.emitIndexed('vobsub', this.state.metadata, false);
1117
2027
  resolve();
1118
2028
  });
1119
2029
  });
@@ -1144,11 +2054,14 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1144
2054
  }
1145
2055
  // Start async lookup if not already pending
1146
2056
  if (!this.pendingIndexLookup) {
1147
- this.pendingIndexLookup = sendToWorker({
2057
+ const presentationToken = this.getPresentationToken();
2058
+ const lookup = sendToWorker({
1148
2059
  type: 'findVobSubIndex',
1149
2060
  sessionId: this.state.sessionId,
1150
2061
  timeMs
1151
2062
  }).then((response) => {
2063
+ if (!this.isCurrentPresentation(presentationToken))
2064
+ return this.cachedIndex;
1152
2065
  if (response.type === 'vobSubIndex') {
1153
2066
  const newIndex = response.index;
1154
2067
  const oldIndex = this.cachedIndex;
@@ -1159,9 +2072,18 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1159
2072
  this.lastRenderedIndex = -2; // Use -2 to force update even when new index is -1
1160
2073
  }
1161
2074
  }
1162
- this.pendingIndexLookup = null;
1163
2075
  return this.cachedIndex;
1164
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
+ });
1165
2087
  }
1166
2088
  return this.cachedIndex;
1167
2089
  }
@@ -1194,12 +2116,9 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1194
2116
  setCachedFrame(this.state, index, frame, renderIssue);
1195
2117
  this.state.pendingRenders.delete(index);
1196
2118
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
1197
- // Force re-render on next frame by resetting lastRenderedIndex
1198
- if (this.findCurrentIndex(this.video.currentTime + this.timeOffset) === index) {
1199
- this.lastRenderedIndex = -1;
1200
- }
1201
2119
  });
1202
2120
  }
2121
+ this.watchPendingRender(index, this.state.pendingRenders.get(index));
1203
2122
  // Return undefined to indicate async loading in progress
1204
2123
  return undefined;
1205
2124
  }
@@ -1218,6 +2137,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1218
2137
  const startTime = this.state.timestamps[index];
1219
2138
  const endTime = this.state.timestamps[index + 1] ?? startTime + 5000;
1220
2139
  const frame = this.state.frameCache.get(index) ?? null;
2140
+ const offscreenFrame = this.getOffscreenFrameMetadata(index);
1221
2141
  return {
1222
2142
  index,
1223
2143
  format: 'vobsub',
@@ -1226,8 +2146,8 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1226
2146
  duration: Math.max(0, endTime - startTime),
1227
2147
  screenWidth: metadata.screenWidth,
1228
2148
  screenHeight: metadata.screenHeight,
1229
- bounds: frame ? getSubtitleBounds(frame) : null,
1230
- compositionCount: frame?.compositionData.length ?? 0,
2149
+ bounds: frame ? getSubtitleBounds(frame) : (offscreenFrame?.bounds ?? null),
2150
+ compositionCount: frame?.compositionData.length ?? offscreenFrame?.compositionCount ?? 0,
1231
2151
  language: metadata.language ?? null,
1232
2152
  trackId: metadata.trackId ?? null
1233
2153
  };
@@ -1254,9 +2174,11 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1254
2174
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
1255
2175
  }
1256
2176
  clearFrameCache() {
2177
+ this.invalidatePresentation();
1257
2178
  this.state.frameCache.clear();
1258
2179
  this.state.renderIssues.clear();
1259
2180
  this.state.pendingRenders.clear();
2181
+ this.clearOffscreenFrameMetadata();
1260
2182
  this.cachedIndex = -1;
1261
2183
  this.cachedIndexTime = -1;
1262
2184
  this.pendingIndexLookup = null;
@@ -1266,6 +2188,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1266
2188
  }
1267
2189
  this.vobsubParser?.clearCache();
1268
2190
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
2191
+ this.renderPausedFrame();
1269
2192
  }
1270
2193
  async prefetchRange(startIndex, endIndex) {
1271
2194
  const safeStart = Math.max(0, Math.min(startIndex, endIndex));
@@ -1302,10 +2225,13 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1302
2225
  sendToWorker({ type: 'setVobSubDebandEnabled', sessionId: this.state.sessionId, enabled }).catch(() => { });
1303
2226
  }
1304
2227
  this.vobsubParser?.setDebandEnabled(enabled);
2228
+ this.invalidatePresentation();
1305
2229
  // Clear cache to force re-render with new settings
1306
2230
  this.state.frameCache.clear();
2231
+ this.clearOffscreenFrameMetadata();
1307
2232
  this.lastRenderedIndex = -1;
1308
2233
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
2234
+ this.renderPausedFrame();
1309
2235
  }
1310
2236
  /** Set debanding threshold (0-255, default: 64) */
1311
2237
  setDebandThreshold(threshold) {
@@ -1313,10 +2239,13 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1313
2239
  sendToWorker({ type: 'setVobSubDebandThreshold', sessionId: this.state.sessionId, threshold }).catch(() => { });
1314
2240
  }
1315
2241
  this.vobsubParser?.setDebandThreshold(threshold);
2242
+ this.invalidatePresentation();
1316
2243
  // Clear cache to force re-render with new settings
1317
2244
  this.state.frameCache.clear();
2245
+ this.clearOffscreenFrameMetadata();
1318
2246
  this.lastRenderedIndex = -1;
1319
2247
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
2248
+ this.renderPausedFrame();
1320
2249
  }
1321
2250
  /** Set debanding sample range in pixels (1-64, default: 15) */
1322
2251
  setDebandRange(range) {
@@ -1324,10 +2253,13 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1324
2253
  sendToWorker({ type: 'setVobSubDebandRange', sessionId: this.state.sessionId, range }).catch(() => { });
1325
2254
  }
1326
2255
  this.vobsubParser?.setDebandRange(range);
2256
+ this.invalidatePresentation();
1327
2257
  // Clear cache to force re-render with new settings
1328
2258
  this.state.frameCache.clear();
2259
+ this.clearOffscreenFrameMetadata();
1329
2260
  this.lastRenderedIndex = -1;
1330
2261
  this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
2262
+ this.renderPausedFrame();
1331
2263
  }
1332
2264
  /** Check if debanding is enabled */
1333
2265
  get debandEnabled() {
@@ -1346,6 +2278,557 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1346
2278
  this.state.sessionId = null;
1347
2279
  }
1348
2280
  }
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
+ */
1349
2832
  /** Create a video subtitle renderer with automatic format detection. */
1350
2833
  export function createAutoSubtitleRenderer(options) {
1351
2834
  const format = detectSubtitleFormat({
@@ -1358,6 +2841,9 @@ export function createAutoSubtitleRenderer(options) {
1358
2841
  if (format === 'pgs') {
1359
2842
  return new PgsRenderer(options);
1360
2843
  }
2844
+ if (format === 'dvb') {
2845
+ return new DvbRenderer(options);
2846
+ }
1361
2847
  if (format === 'vobsub') {
1362
2848
  return new VobSubRenderer(options);
1363
2849
  }