libbitsub 1.12.1 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@ import { fetchSubtitleAsset, fetchSubtitleText } from './range-loader';
7
7
  import { initWasm } from './wasm';
8
8
  import { getOrCreateWorker, sendToWorker } from './worker';
9
9
  import { canUseWorkerOffscreenRender } from './capabilities';
10
- import { binarySearchTimestamp, convertFrameData, createWorkerSessionId, createWorkerState, detectSubtitleFormat, getSubtitleBounds, isMksSource, setCacheLimit as applyCacheLimit, setCachedFrame } from './utils';
10
+ import { binarySearchTimestamp, convertFrameData, createWorkerSessionId, createWorkerState, detectSubtitleFormat, getSubtitleBounds, isMksSource, setCacheLimit as applyCacheLimit, setCachedFrame, spliceTimestamps } from './utils';
11
11
  import { PgsParser, DvbParser, VobSubParserLowLevel } from './parsers';
12
12
  import { WebGPURenderer, isWebGPUSupported } from './webgpu-renderer';
13
13
  import { WebGL2Renderer, isWebGL2Supported } from './webgl2-renderer';
@@ -58,6 +58,8 @@ class BaseVideoSubtitleRenderer {
58
58
  tempCanvas = null;
59
59
  tempCtx = null;
60
60
  lastRenderedData = null;
61
+ /** Clear the canvas instead of holding the previous cue while the next bitmap decodes. */
62
+ clearStaleWhilePending = false;
61
63
  lastCueIndex = null;
62
64
  currentCueMetadata = null;
63
65
  parserMetadata = null;
@@ -69,6 +71,9 @@ class BaseVideoSubtitleRenderer {
69
71
  prefetchAfter = 0;
70
72
  streamingLoad = true;
71
73
  rangeRequests = true;
74
+ maxSubtitleBytes;
75
+ /** Aborted on dispose to cancel in-flight subtitle downloads. */
76
+ lifecycle = new AbortController();
72
77
  onEvent;
73
78
  onWarning;
74
79
  currentRendererBackend = null;
@@ -134,6 +139,20 @@ class BaseVideoSubtitleRenderer {
134
139
  this.prefetchAfter = Math.max(0, Math.floor(options.prefetchWindow?.after ?? 0));
135
140
  this.streamingLoad = options.streamingLoad !== false;
136
141
  this.rangeRequests = options.rangeRequests !== false;
142
+ this.maxSubtitleBytes = options.maxSubtitleBytes;
143
+ }
144
+ /** Fetch options bound to this renderer's lifetime and download budget. */
145
+ fetchOptions(preferRange, onProgress) {
146
+ return { preferRange, onProgress, signal: this.lifecycle.signal, maxBytes: this.maxSubtitleBytes };
147
+ }
148
+ /** Dispose this renderer's parser in the shared worker, if a session was started. */
149
+ releaseWorkerSession(state) {
150
+ state.workerReady = false;
151
+ const sessionId = state.sessionId;
152
+ if (!sessionId)
153
+ return;
154
+ const type = this.format === 'pgs' ? 'disposePgs' : this.format === 'dvb' ? 'disposeDvb' : 'disposeVobSub';
155
+ sendToWorker({ type, sessionId }).catch(() => { });
137
156
  }
138
157
  emitLoadProgress(format, progress, indexedCues) {
139
158
  this.emitEvent({
@@ -280,6 +299,8 @@ class BaseVideoSubtitleRenderer {
280
299
  startInit() {
281
300
  this.initPromise = this.init();
282
301
  this.initPromise.catch((error) => {
302
+ if (this.disposed)
303
+ return;
283
304
  this.emitEvent({
284
305
  type: 'error',
285
306
  format: this.format,
@@ -330,10 +351,20 @@ class BaseVideoSubtitleRenderer {
330
351
  /** Initialize the renderer. */
331
352
  async init() {
332
353
  await initWasm();
354
+ if (this.disposed)
355
+ return;
333
356
  this.createCanvas();
334
357
  await new Promise((resolve) => setTimeout(resolve, 0));
358
+ if (this.disposed)
359
+ return;
335
360
  await this.loadSubtitles();
361
+ // Loading may have created parsers or worker sessions after dispose() ran;
362
+ // dispose again to release them.
363
+ if (this.disposed)
364
+ return this.dispose();
336
365
  await this.ensureWorkerOffscreenAttached();
366
+ if (this.disposed)
367
+ return this.dispose();
337
368
  this.startRenderLoop();
338
369
  }
339
370
  shouldPreferWorkerOffscreen() {
@@ -432,11 +463,13 @@ class BaseVideoSubtitleRenderer {
432
463
  this.lastRenderedIndex = -1;
433
464
  this.lastRenderedTime = -1;
434
465
  this.lastSynchronizedTime = null;
466
+ this.lastRenderedData = null;
435
467
  this.offscreenFrameMetadata.clear();
436
468
  this.onSeek();
437
469
  this.renderPausedFrame();
438
470
  };
439
471
  this.video.addEventListener('loadedmetadata', this.loadedMetadataHandler);
472
+ this.video.addEventListener('resize', this.loadedMetadataHandler);
440
473
  this.video.addEventListener('seeked', this.seekedHandler);
441
474
  }
442
475
  preferWorkerOffscreenOrCanvas2D() {
@@ -498,8 +531,6 @@ class BaseVideoSubtitleRenderer {
498
531
  }
499
532
  this.useWorkerOffscreen = true;
500
533
  this.emitRendererBackend('worker-offscreen');
501
- this.lastRenderedIndex = -1;
502
- this.lastRenderedTime = -1;
503
534
  await sendToWorker({
504
535
  type: 'resizeOffscreenCanvas',
505
536
  sessionId,
@@ -518,6 +549,8 @@ class BaseVideoSubtitleRenderer {
518
549
  details: { reason: error instanceof Error ? error.message : String(error) }
519
550
  }));
520
551
  }
552
+ if (this.useWorkerOffscreen)
553
+ this.reconcileBackendPresentation();
521
554
  }
522
555
  recreateCanvasForMainThreadFallback() {
523
556
  this.removeOwnedCanvas();
@@ -600,68 +633,112 @@ class BaseVideoSubtitleRenderer {
600
633
  }
601
634
  /** Initialize WebGPU renderer. */
602
635
  async initWebGPU(allowBackendFallback = true) {
636
+ if (this.disposed)
637
+ return;
638
+ let renderer = null;
603
639
  try {
604
- this.webgpuRenderer = new WebGPURenderer();
605
- await this.webgpuRenderer.init();
606
- if (!this.canvas)
640
+ renderer = new WebGPURenderer();
641
+ this.webgpuRenderer = renderer;
642
+ await renderer.init();
643
+ if (this.disposed || !this.canvas) {
644
+ renderer.destroy();
607
645
  return;
646
+ }
608
647
  const bounds = this.getVideoContentBounds();
609
648
  const pixelRatio = this.getDevicePixelRatio();
610
649
  const width = Math.max(1, bounds.width * pixelRatio);
611
650
  const height = Math.max(1, bounds.height * pixelRatio);
612
- await this.webgpuRenderer.setCanvas(this.canvas, width, height);
613
- this.useWebGPU = true;
614
- this.emitRendererBackend('webgpu');
651
+ await renderer.setCanvas(this.canvas, width, height);
652
+ if (this.disposed) {
653
+ renderer.destroy();
654
+ return;
655
+ }
615
656
  }
616
657
  catch (error) {
617
- this.webgpuRenderer?.destroy();
658
+ renderer?.destroy();
659
+ if (this.disposed)
660
+ return;
618
661
  this.webgpuRenderer = null;
619
662
  this.useWebGPU = false;
620
663
  this.onWebGPUFallback?.();
664
+ if (this.disposed)
665
+ return;
621
666
  if (allowBackendFallback && isWebGL2Supported()) {
622
667
  void this.initWebGL2();
623
668
  }
624
669
  else {
625
670
  this.preferWorkerOffscreenOrCanvas2D();
626
671
  }
672
+ return;
627
673
  }
674
+ this.useWebGPU = true;
675
+ this.emitRendererBackend('webgpu');
676
+ this.reconcileBackendPresentation();
628
677
  }
629
678
  /** Initialize WebGL2 renderer. */
630
679
  async initWebGL2(allowBackendFallback = true) {
680
+ if (this.disposed)
681
+ return;
682
+ let renderer = null;
631
683
  try {
632
- this.webgl2Renderer = new WebGL2Renderer();
633
- await this.webgl2Renderer.init();
634
- if (!this.canvas)
684
+ renderer = new WebGL2Renderer();
685
+ this.webgl2Renderer = renderer;
686
+ await renderer.init();
687
+ if (this.disposed || !this.canvas) {
688
+ renderer.destroy();
635
689
  return;
690
+ }
636
691
  const bounds = this.getVideoContentBounds();
637
692
  const pixelRatio = this.getDevicePixelRatio();
638
693
  const width = Math.max(1, bounds.width * pixelRatio);
639
694
  const height = Math.max(1, bounds.height * pixelRatio);
640
- await this.webgl2Renderer.setCanvas(this.canvas, width, height);
641
- this.useWebGL2 = true;
642
- this.emitRendererBackend('webgl2');
695
+ await renderer.setCanvas(this.canvas, width, height);
696
+ if (this.disposed) {
697
+ renderer.destroy();
698
+ return;
699
+ }
643
700
  }
644
701
  catch (error) {
645
- this.webgl2Renderer?.destroy();
702
+ renderer?.destroy();
703
+ if (this.disposed)
704
+ return;
646
705
  this.webgl2Renderer = null;
647
706
  this.useWebGL2 = false;
648
707
  this.onWebGL2Fallback?.();
708
+ if (this.disposed)
709
+ return;
649
710
  if (allowBackendFallback) {
650
711
  this.preferWorkerOffscreenOrCanvas2D();
651
712
  }
652
713
  else {
653
714
  this.initCanvas2D();
654
715
  }
716
+ return;
655
717
  }
718
+ this.useWebGL2 = true;
719
+ this.emitRendererBackend('webgl2');
720
+ this.reconcileBackendPresentation();
656
721
  }
657
722
  /** Initialize Canvas2D renderer. */
658
723
  initCanvas2D() {
659
- if (!this.canvas)
724
+ if (this.disposed || !this.canvas)
660
725
  return;
661
726
  this.ctx = this.canvas.getContext('2d');
662
727
  this.useWebGPU = false;
663
728
  this.useWebGL2 = false;
664
729
  this.emitRendererBackend('canvas2d');
730
+ this.reconcileBackendPresentation();
731
+ }
732
+ /** A cue decoded before backend startup has not yet reached the canvas. */
733
+ reconcileBackendPresentation() {
734
+ if (this.disposed)
735
+ return;
736
+ this.invalidatePresentation();
737
+ this.lastRenderedIndex = -2;
738
+ this.lastRenderedTime = -1;
739
+ // Loading creates the temporary drawing canvas before starting presentation.
740
+ if (this.frameScheduler)
741
+ this.renderPausedFrame();
665
742
  }
666
743
  /** Called when video seeks. */
667
744
  onSeek() { }
@@ -779,6 +856,13 @@ class BaseVideoSubtitleRenderer {
779
856
  return;
780
857
  this.renderSynchronizedFrame({ mediaTime: this.video.currentTime, presentedFrames: null });
781
858
  }
859
+ /**
860
+ * Render the cue for a media time synchronously, whether or not the video is paused.
861
+ * Hosts call this from their own presented-frame callback so a cached cue paints on that frame.
862
+ */
863
+ renderAtMediaTime(mediaTime) {
864
+ this.renderSynchronizedFrame({ mediaTime, presentedFrames: null });
865
+ }
782
866
  renderSynchronizedFrame(tick) {
783
867
  if (this.disposed || !this.isLoaded)
784
868
  return;
@@ -853,7 +937,7 @@ class BaseVideoSubtitleRenderer {
853
937
  if (this.findCurrentIndex(this.getCurrentSynchronizationTime()) !== index)
854
938
  return;
855
939
  this.lastRenderedIndex = -1;
856
- this.renderPausedFrame();
940
+ this.renderAtMediaTime(this.getCurrentSynchronizationTime() - this.timeOffset);
857
941
  }, () => { });
858
942
  }
859
943
  recordRenderPerformance(renderTime, capturedAt, countAsDropped = true) {
@@ -904,12 +988,17 @@ class BaseVideoSubtitleRenderer {
904
988
  // If data is undefined, it means async loading is in progress
905
989
  // Keep showing the last frame only while waiting for async data
906
990
  // Note: null means "loaded but empty" (clear screen), undefined means "still loading"
991
+ let stalePending = false;
907
992
  if (data === undefined && this.lastRenderedData !== null && index >= 0) {
908
993
  // Check if this index has a pending render (truly async loading)
909
994
  // If not pending, it means the render returned no data immediately
910
995
  if (this.isPendingRender(index)) {
911
- // Don't clear - keep showing the last frame while loading
912
- return { status: 'pending', data: null, warning: null };
996
+ if (!this.clearStaleWhilePending) {
997
+ // Don't clear - keep showing the last frame while loading
998
+ return { status: 'pending', data: null, warning: null };
999
+ }
1000
+ // The previous cue has ended: clear it below instead of holding it
1001
+ stalePending = true;
913
1002
  }
914
1003
  }
915
1004
  const renderIssue = index >= 0 ? (this.getWorkerRendererState().renderIssues.get(index) ?? null) : null;
@@ -927,6 +1016,9 @@ class BaseVideoSubtitleRenderer {
927
1016
  if (index < 0) {
928
1017
  return { status: 'cleared', data: null, warning: null };
929
1018
  }
1019
+ if (stalePending) {
1020
+ return { status: 'pending', data: null, warning: null };
1021
+ }
930
1022
  if (warning) {
931
1023
  return { status: 'failed', data: data ?? null, warning };
932
1024
  }
@@ -1141,6 +1233,7 @@ class BaseVideoSubtitleRenderer {
1141
1233
  /** Dispose of all resources. */
1142
1234
  dispose() {
1143
1235
  this.disposed = true;
1236
+ this.lifecycle.abort();
1144
1237
  this.invalidatePresentation();
1145
1238
  this.frameScheduler?.stop();
1146
1239
  this.frameScheduler = null;
@@ -1148,6 +1241,7 @@ class BaseVideoSubtitleRenderer {
1148
1241
  this.resizeObserver = null;
1149
1242
  if (this.loadedMetadataHandler) {
1150
1243
  this.video.removeEventListener('loadedmetadata', this.loadedMetadataHandler);
1244
+ this.video.removeEventListener('resize', this.loadedMetadataHandler);
1151
1245
  this.loadedMetadataHandler = null;
1152
1246
  }
1153
1247
  if (this.seekedHandler) {
@@ -1222,10 +1316,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1222
1316
  return;
1223
1317
  }
1224
1318
  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
- });
1319
+ const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(this.subUrl, this.fetchOptions(this.rangeRequests, (progress) => this.emitLoadProgress('pgs', progress, this.state.timestamps.length)));
1229
1320
  this.emitLoadProgress('pgs', { loaded: data.byteLength, total: total ?? data.byteLength, ratio: 1, rangeSupported, strategy }, 0);
1230
1321
  await this.loadPgsBuffer(data, false);
1231
1322
  this.onLoaded?.();
@@ -1235,11 +1326,19 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1235
1326
  this.onLoaded?.();
1236
1327
  }
1237
1328
  catch (error) {
1329
+ if (this.disposed)
1330
+ return;
1238
1331
  const resolvedError = normalizeSubtitleError(error, { format: 'pgs' });
1239
1332
  this.emitEvent({ type: 'error', format: 'pgs', error: resolvedError });
1240
1333
  this.onError?.(resolvedError);
1241
1334
  }
1242
1335
  }
1336
+ /** Resolve an append response, which may carry only new timestamps, to the full list. */
1337
+ mergePgsTimestamps(response) {
1338
+ if (response.timestampBase === undefined)
1339
+ return response.timestamps;
1340
+ return spliceTimestamps(this.state.timestamps, response.timestampBase, response.timestamps);
1341
+ }
1243
1342
  applyPgsIndexState(metadata, timestamps, partial, usingWorker) {
1244
1343
  this.state.metadata = metadata;
1245
1344
  this.state.timestamps = timestamps;
@@ -1272,7 +1371,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1272
1371
  }
1273
1372
  catch (workerError) {
1274
1373
  this.state.useWorker = false;
1275
- this.state.workerReady = false;
1374
+ this.releaseWorkerSession(this.state);
1276
1375
  this.state.sessionId = null;
1277
1376
  this.emitWorkerState(false, false, null, true);
1278
1377
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'PGS worker initialization failed, falling back to main-thread rendering.', {
@@ -1323,12 +1422,13 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1323
1422
  if (response.type !== 'pgsProgress')
1324
1423
  throw new Error('Unexpected PGS worker response');
1325
1424
  added = response.added;
1425
+ const timestamps = this.mergePgsTimestamps(response);
1326
1426
  if (added > 0) {
1327
- this.applyPgsIndexState(response.metadata, response.timestamps, true, true);
1427
+ this.applyPgsIndexState(response.metadata, timestamps, true, true);
1328
1428
  }
1329
1429
  else {
1330
1430
  this.state.metadata = response.metadata;
1331
- this.state.timestamps = response.timestamps;
1431
+ this.state.timestamps = timestamps;
1332
1432
  }
1333
1433
  }
1334
1434
  else {
@@ -1426,6 +1526,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1426
1526
  catch (workerError) {
1427
1527
  this.state.useWorker = false;
1428
1528
  this.emitWorkerState(false, false, this.state.sessionId, true);
1529
+ this.releaseWorkerSession(this.state);
1429
1530
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'PGS worker initialization failed, falling back to main-thread rendering.', {
1430
1531
  format: 'pgs',
1431
1532
  details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
@@ -1451,6 +1552,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1451
1552
  this.state.useWorker = false;
1452
1553
  usedWorker = false;
1453
1554
  this.emitWorkerState(false, false, this.state.sessionId, true);
1555
+ this.releaseWorkerSession(this.state);
1454
1556
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'PGS worker initialization failed, falling back to main-thread rendering.', {
1455
1557
  format: 'pgs',
1456
1558
  details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
@@ -1463,10 +1565,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1463
1565
  this.pgsParser.reset();
1464
1566
  }
1465
1567
  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) => {
1568
+ const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(url, this.fetchOptions(this.rangeRequests, (progress) => this.emitLoadProgress('pgs', progress, this.state.timestamps.length)), async (chunk, progress) => {
1470
1569
  if (chunk.byteLength === 0)
1471
1570
  return;
1472
1571
  if (usedWorker && this.state.sessionId) {
@@ -1477,12 +1576,13 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1477
1576
  data: transferable
1478
1577
  });
1479
1578
  if (response.type === 'pgsProgress') {
1579
+ const timestamps = this.mergePgsTimestamps(response);
1480
1580
  if (response.added > 0 || !indexedOnce) {
1481
- this.applyPgsIndexState(response.metadata, response.timestamps, true, true);
1581
+ this.applyPgsIndexState(response.metadata, timestamps, true, true);
1482
1582
  indexedOnce = true;
1483
1583
  }
1484
1584
  else {
1485
- this.state.timestamps = response.timestamps;
1585
+ this.state.timestamps = timestamps;
1486
1586
  this.state.metadata = response.metadata;
1487
1587
  }
1488
1588
  }
@@ -1536,12 +1636,15 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1536
1636
  if (usedWorker) {
1537
1637
  this.state.useWorker = false;
1538
1638
  this.emitWorkerState(false, false, this.state.sessionId, true);
1639
+ this.releaseWorkerSession(this.state);
1539
1640
  }
1641
+ if (this.disposed)
1642
+ throw error;
1540
1643
  this.emitWarning(createSubtitleWarning('RANGE_FALLBACK', 'Progressive PGS load failed; retrying with a full buffer fetch.', {
1541
1644
  format: 'pgs',
1542
1645
  details: { reason: error instanceof Error ? error.message : String(error) }
1543
1646
  }));
1544
- const { data } = await fetchSubtitleAsset(url, { preferRange: this.rangeRequests });
1647
+ const { data } = await fetchSubtitleAsset(url, this.fetchOptions(this.rangeRequests));
1545
1648
  await this.loadPgsBuffer(data, false);
1546
1649
  }
1547
1650
  }
@@ -1718,12 +1821,9 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1718
1821
  this.state.frameCache.clear();
1719
1822
  this.state.renderIssues.clear();
1720
1823
  this.state.pendingRenders.clear();
1721
- if (this.state.useWorker && this.state.workerReady) {
1722
- sendToWorker({ type: 'disposePgs', sessionId: this.state.sessionId }).catch(() => { });
1723
- }
1824
+ this.releaseWorkerSession(this.state);
1724
1825
  this.pgsParser?.dispose();
1725
1826
  this.pgsParser = null;
1726
- this.state.sessionId = null;
1727
1827
  }
1728
1828
  }
1729
1829
  /**
@@ -1739,10 +1839,8 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1739
1839
  onLoading;
1740
1840
  onLoaded;
1741
1841
  onError;
1742
- // Async index lookup state
1743
- cachedIndex = -1;
1744
- cachedIndexTime = -1;
1745
- pendingIndexLookup = null;
1842
+ // Cue end times (ms) for the main-thread index lookup in worker mode; starts live in state.timestamps
1843
+ cueEnds = null;
1746
1844
  constructor(options) {
1747
1845
  super(options, 'vobsub');
1748
1846
  this.idxUrl =
@@ -1753,6 +1851,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1753
1851
  this.onLoading = options.onLoading;
1754
1852
  this.onLoaded = options.onLoaded;
1755
1853
  this.onError = options.onError;
1854
+ this.clearStaleWhilePending = true;
1756
1855
  applyCacheLimit(this.state, this.cacheLimit);
1757
1856
  this.startInit();
1758
1857
  }
@@ -1783,6 +1882,8 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1783
1882
  this.onLoaded?.();
1784
1883
  }
1785
1884
  catch (error) {
1885
+ if (this.disposed)
1886
+ return;
1786
1887
  const resolvedError = normalizeSubtitleError(error, { format: 'vobsub' });
1787
1888
  this.emitEvent({ type: 'error', format: 'vobsub', error: resolvedError });
1788
1889
  this.onError?.(resolvedError);
@@ -1813,6 +1914,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1813
1914
  catch (workerError) {
1814
1915
  this.state.useWorker = false;
1815
1916
  this.emitWorkerState(false, false, this.state.sessionId, true);
1917
+ this.releaseWorkerSession(this.state);
1816
1918
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'VobSub worker initialization failed, falling back to main-thread rendering.', {
1817
1919
  format: 'vobsub',
1818
1920
  details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
@@ -1845,6 +1947,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1845
1947
  this.state.workerReady = true;
1846
1948
  this.state.metadata = loadResponse.metadata;
1847
1949
  this.state.timestamps = loadResponse.timestamps;
1950
+ await this.loadCueTable();
1848
1951
  this.isLoaded = true;
1849
1952
  this.setParserMetadata(loadResponse.metadata);
1850
1953
  this.emitIndexed('vobsub', loadResponse.metadata, false);
@@ -1861,6 +1964,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1861
1964
  }
1862
1965
  this.state.useWorker = false;
1863
1966
  this.emitWorkerState(false, false, this.state.sessionId, true);
1967
+ this.releaseWorkerSession(this.state);
1864
1968
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'VobSub worker load failed, falling back to main-thread rendering.', {
1865
1969
  format: 'vobsub',
1866
1970
  details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
@@ -1877,10 +1981,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1877
1981
  if (!this.subUrl) {
1878
1982
  throw createSubtitleDiagnosticError('MISSING_INPUT', 'No SUB content or URL provided.', { format: 'vobsub' });
1879
1983
  }
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
- });
1984
+ const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(this.subUrl, this.fetchOptions(this.rangeRequests && this.streamingLoad, (progress) => this.emitLoadProgress('vobsub', progress, this.state.timestamps.length)));
1884
1985
  this.emitLoadProgress('vobsub', {
1885
1986
  loaded: data.byteLength,
1886
1987
  total: total ?? data.byteLength,
@@ -1896,9 +1997,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1896
1997
  if (!this.idxUrl) {
1897
1998
  throw createSubtitleDiagnosticError('MISSING_INPUT', 'No IDX content or URL provided.', { format: 'vobsub' });
1898
1999
  }
1899
- idxData = await fetchSubtitleText(this.idxUrl, {
1900
- onProgress: (progress) => this.emitLoadProgress('vobsub', progress, 0)
1901
- });
2000
+ idxData = await fetchSubtitleText(this.idxUrl, this.fetchOptions(false, (progress) => this.emitLoadProgress('vobsub', progress, 0)));
1902
2001
  }
1903
2002
  let usedWorker = await this.ensureVobSubWorkerSession();
1904
2003
  if (usedWorker && this.state.sessionId) {
@@ -1929,6 +2028,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1929
2028
  usedWorker = false;
1930
2029
  this.state.useWorker = false;
1931
2030
  this.emitWorkerState(false, false, this.state.sessionId, true);
2031
+ this.releaseWorkerSession(this.state);
1932
2032
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'VobSub worker IDX load failed, falling back to main-thread rendering.', {
1933
2033
  format: 'vobsub',
1934
2034
  details: { reason: error instanceof Error ? error.message : String(error) }
@@ -1953,10 +2053,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1953
2053
  if (!this.subUrl) {
1954
2054
  throw createSubtitleDiagnosticError('MISSING_INPUT', 'No SUB content or URL provided.', { format: 'vobsub' });
1955
2055
  }
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
- });
2056
+ const fetched = await fetchSubtitleAsset(this.subUrl, this.fetchOptions(this.rangeRequests && this.streamingLoad, (progress) => this.emitLoadProgress('vobsub', progress, this.state.timestamps.length)));
1960
2057
  subData = fetched.data;
1961
2058
  this.emitLoadProgress('vobsub', {
1962
2059
  loaded: subData.byteLength,
@@ -1976,6 +2073,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1976
2073
  if (attachResponse.type === 'vobSubProgress') {
1977
2074
  this.applyVobSubIndexState(attachResponse.metadata, attachResponse.timestamps, false, true, true);
1978
2075
  this.state.workerReady = true;
2076
+ await this.loadCueTable();
1979
2077
  this.isLoaded = true;
1980
2078
  this.emitWorkerState(true, true, this.state.sessionId);
1981
2079
  return;
@@ -2045,50 +2143,53 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
2045
2143
  }
2046
2144
  findCurrentIndex(time) {
2047
2145
  if (this.state.useWorker && this.state.workerReady) {
2048
- const timeMs = time * 1000;
2049
- // Only use cache if time is very close (within 1 frame)
2050
- const timeDelta = timeMs - this.cachedIndexTime;
2051
- const cacheValid = this.cachedIndexTime >= 0 && Math.abs(timeDelta) < 17;
2052
- if (cacheValid) {
2053
- return this.cachedIndex;
2054
- }
2055
- // Start async lookup if not already pending
2056
- if (!this.pendingIndexLookup) {
2057
- const presentationToken = this.getPresentationToken();
2058
- const lookup = sendToWorker({
2059
- type: 'findVobSubIndex',
2060
- sessionId: this.state.sessionId,
2061
- timeMs
2062
- }).then((response) => {
2063
- if (!this.isCurrentPresentation(presentationToken))
2064
- return this.cachedIndex;
2065
- if (response.type === 'vobSubIndex') {
2066
- const newIndex = response.index;
2067
- const oldIndex = this.cachedIndex;
2068
- this.cachedIndex = newIndex;
2069
- this.cachedIndexTime = timeMs;
2070
- // Force re-render if index changed (including to -1 for clear)
2071
- if (oldIndex !== newIndex) {
2072
- this.lastRenderedIndex = -2; // Use -2 to force update even when new index is -1
2073
- }
2074
- }
2075
- return this.cachedIndex;
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
- });
2087
- }
2088
- return this.cachedIndex;
2146
+ return this.findIndexInCueTable(time);
2089
2147
  }
2090
2148
  return this.vobsubParser?.findIndexAtTimestamp(time) ?? -1;
2091
2149
  }
2150
+ /** Synchronous lookup against the worker's cue table; mirrors the core parser's find_index_at_timestamp. */
2151
+ findIndexInCueTable(time) {
2152
+ const starts = this.state.timestamps;
2153
+ const ends = this.cueEnds;
2154
+ if (!ends || starts.length === 0)
2155
+ return -1;
2156
+ const timeMs = Math.max(0, Math.floor(time * 1000));
2157
+ let low = 0;
2158
+ let high = starts.length;
2159
+ while (low < high) {
2160
+ const mid = low + ((high - low) >> 1);
2161
+ if (starts[mid] <= timeMs)
2162
+ low = mid + 1;
2163
+ else
2164
+ high = mid;
2165
+ }
2166
+ const index = low > 0 ? low - 1 : 0;
2167
+ if (timeMs < starts[index] || timeMs >= ends[index])
2168
+ return -1;
2169
+ return index;
2170
+ }
2171
+ /** Fetch cue end times once, after the worker holds the SUB data. Falls back to next-start ends. */
2172
+ async loadCueTable() {
2173
+ const starts = this.state.timestamps;
2174
+ let ends = null;
2175
+ try {
2176
+ const response = await sendToWorker({ type: 'getVobSubTimestamps', sessionId: this.state.sessionId });
2177
+ const received = response.endTimestamps;
2178
+ if (received && received.length === starts.length)
2179
+ ends = received;
2180
+ }
2181
+ catch {
2182
+ /* fall back below */
2183
+ }
2184
+ if (!ends) {
2185
+ ends = new Float64Array(starts.length);
2186
+ for (let i = 0; i < starts.length; i++)
2187
+ ends[i] = starts[i + 1] ?? starts[i] + 5000;
2188
+ }
2189
+ this.cueEnds = ends;
2190
+ this.lastRenderedIndex = -1;
2191
+ this.renderPausedFrame();
2192
+ }
2092
2193
  renderAtIndex(index) {
2093
2194
  if (this.state.frameCache.has(index)) {
2094
2195
  return this.state.frameCache.get(index) ?? undefined;
@@ -2159,10 +2260,6 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
2159
2260
  this.state.frameCache.clear();
2160
2261
  this.state.renderIssues.clear();
2161
2262
  this.state.pendingRenders.clear();
2162
- // Clear cached index lookup on seek
2163
- this.cachedIndex = -1;
2164
- this.cachedIndexTime = -1;
2165
- this.pendingIndexLookup = null;
2166
2263
  if (this.state.useWorker && this.state.workerReady) {
2167
2264
  sendToWorker({ type: 'clearVobSubCache', sessionId: this.state.sessionId }).catch(() => { });
2168
2265
  }
@@ -2179,9 +2276,6 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
2179
2276
  this.state.renderIssues.clear();
2180
2277
  this.state.pendingRenders.clear();
2181
2278
  this.clearOffscreenFrameMetadata();
2182
- this.cachedIndex = -1;
2183
- this.cachedIndexTime = -1;
2184
- this.pendingIndexLookup = null;
2185
2279
  this.lastRenderedIndex = -1;
2186
2280
  if (this.state.useWorker && this.state.workerReady) {
2187
2281
  sendToWorker({ type: 'clearVobSubCache', sessionId: this.state.sessionId }).catch(() => { });
@@ -2270,12 +2364,9 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
2270
2364
  this.state.frameCache.clear();
2271
2365
  this.state.renderIssues.clear();
2272
2366
  this.state.pendingRenders.clear();
2273
- if (this.state.useWorker && this.state.workerReady) {
2274
- sendToWorker({ type: 'disposeVobSub', sessionId: this.state.sessionId }).catch(() => { });
2275
- }
2367
+ this.releaseWorkerSession(this.state);
2276
2368
  this.vobsubParser?.dispose();
2277
2369
  this.vobsubParser = null;
2278
- this.state.sessionId = null;
2279
2370
  }
2280
2371
  }
2281
2372
  /** High-level DVB overlay renderer bound to a video element. */
@@ -2313,10 +2404,7 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2313
2404
  return;
2314
2405
  }
2315
2406
  if (!this.streamingLoad) {
2316
- const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(this.subUrl, {
2317
- preferRange: this.rangeRequests,
2318
- onProgress: (progress) => this.emitLoadProgress('dvb', progress, this.state.timestamps.length)
2319
- });
2407
+ const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(this.subUrl, this.fetchOptions(this.rangeRequests, (progress) => this.emitLoadProgress('dvb', progress, this.state.timestamps.length)));
2320
2408
  this.emitLoadProgress('dvb', { loaded: data.byteLength, total: total ?? data.byteLength, ratio: 1, rangeSupported, strategy }, 0);
2321
2409
  await this.loadDvbBuffer(data, false);
2322
2410
  this.onLoaded?.();
@@ -2326,11 +2414,23 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2326
2414
  this.onLoaded?.();
2327
2415
  }
2328
2416
  catch (error) {
2417
+ if (this.disposed)
2418
+ return;
2329
2419
  const resolvedError = normalizeSubtitleError(error, { format: 'dvb' });
2330
2420
  this.emitEvent({ type: 'error', format: 'dvb', error: resolvedError });
2331
2421
  this.onError?.(resolvedError);
2332
2422
  }
2333
2423
  }
2424
+ /** Resolve an append response, which may carry only changed entries, to the full lists. */
2425
+ mergeDvbTimestamps(response) {
2426
+ const base = response.timestampBase;
2427
+ if (base === undefined)
2428
+ return { timestamps: response.timestamps, endTimestamps: response.endTimestamps };
2429
+ return {
2430
+ timestamps: spliceTimestamps(this.state.timestamps, base, response.timestamps),
2431
+ endTimestamps: spliceTimestamps(this.endTimestamps, base, response.endTimestamps)
2432
+ };
2433
+ }
2334
2434
  applyDvbIndexState(metadata, timestamps, endTimestamps, partial, usingWorker) {
2335
2435
  this.state.metadata = metadata;
2336
2436
  this.state.timestamps = timestamps;
@@ -2364,7 +2464,7 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2364
2464
  }
2365
2465
  catch (workerError) {
2366
2466
  this.state.useWorker = false;
2367
- this.state.workerReady = false;
2467
+ this.releaseWorkerSession(this.state);
2368
2468
  this.state.sessionId = null;
2369
2469
  this.emitWorkerState(false, false, null, true);
2370
2470
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'DVB worker initialization failed, falling back to main-thread rendering.', {
@@ -2415,13 +2515,14 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2415
2515
  if (response.type !== 'dvbProgress')
2416
2516
  throw new Error('Unexpected DVB worker response');
2417
2517
  added = response.added;
2518
+ const { timestamps, endTimestamps } = this.mergeDvbTimestamps(response);
2418
2519
  if (added > 0) {
2419
- this.applyDvbIndexState(response.metadata, response.timestamps, response.endTimestamps, true, true);
2520
+ this.applyDvbIndexState(response.metadata, timestamps, endTimestamps, true, true);
2420
2521
  }
2421
2522
  else {
2422
2523
  this.state.metadata = response.metadata;
2423
- this.state.timestamps = response.timestamps;
2424
- this.endTimestamps = response.endTimestamps;
2524
+ this.state.timestamps = timestamps;
2525
+ this.endTimestamps = endTimestamps;
2425
2526
  }
2426
2527
  }
2427
2528
  else {
@@ -2520,6 +2621,7 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2520
2621
  catch (workerError) {
2521
2622
  this.state.useWorker = false;
2522
2623
  this.emitWorkerState(false, false, this.state.sessionId, true);
2624
+ this.releaseWorkerSession(this.state);
2523
2625
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'DVB worker initialization failed, falling back to main-thread rendering.', {
2524
2626
  format: 'dvb',
2525
2627
  details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
@@ -2545,6 +2647,7 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2545
2647
  this.state.useWorker = false;
2546
2648
  usedWorker = false;
2547
2649
  this.emitWorkerState(false, false, this.state.sessionId, true);
2650
+ this.releaseWorkerSession(this.state);
2548
2651
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'DVB worker initialization failed, falling back to main-thread rendering.', {
2549
2652
  format: 'dvb',
2550
2653
  details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
@@ -2557,10 +2660,7 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2557
2660
  this.dvbParser.reset();
2558
2661
  }
2559
2662
  try {
2560
- const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(url, {
2561
- preferRange: this.rangeRequests,
2562
- onProgress: (progress) => this.emitLoadProgress('dvb', progress, this.state.timestamps.length)
2563
- }, async (chunk, progress) => {
2663
+ const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(url, this.fetchOptions(this.rangeRequests, (progress) => this.emitLoadProgress('dvb', progress, this.state.timestamps.length)), async (chunk, progress) => {
2564
2664
  if (chunk.byteLength === 0)
2565
2665
  return;
2566
2666
  if (usedWorker && this.state.sessionId) {
@@ -2571,13 +2671,14 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2571
2671
  data: transferable
2572
2672
  });
2573
2673
  if (response.type === 'dvbProgress') {
2674
+ const { timestamps, endTimestamps } = this.mergeDvbTimestamps(response);
2574
2675
  if (response.added > 0 || !indexedOnce) {
2575
- this.applyDvbIndexState(response.metadata, response.timestamps, response.endTimestamps, true, true);
2676
+ this.applyDvbIndexState(response.metadata, timestamps, endTimestamps, true, true);
2576
2677
  indexedOnce = true;
2577
2678
  }
2578
2679
  else {
2579
- this.state.timestamps = response.timestamps;
2580
- this.endTimestamps = response.endTimestamps;
2680
+ this.state.timestamps = timestamps;
2681
+ this.endTimestamps = endTimestamps;
2581
2682
  this.state.metadata = response.metadata;
2582
2683
  }
2583
2684
  }
@@ -2632,12 +2733,15 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2632
2733
  if (usedWorker) {
2633
2734
  this.state.useWorker = false;
2634
2735
  this.emitWorkerState(false, false, this.state.sessionId, true);
2736
+ this.releaseWorkerSession(this.state);
2635
2737
  }
2738
+ if (this.disposed)
2739
+ throw error;
2636
2740
  this.emitWarning(createSubtitleWarning('RANGE_FALLBACK', 'Progressive DVB load failed; retrying with a full buffer fetch.', {
2637
2741
  format: 'dvb',
2638
2742
  details: { reason: error instanceof Error ? error.message : String(error) }
2639
2743
  }));
2640
- const { data } = await fetchSubtitleAsset(url, { preferRange: this.rangeRequests });
2744
+ const { data } = await fetchSubtitleAsset(url, this.fetchOptions(this.rangeRequests));
2641
2745
  await this.loadDvbBuffer(data, false);
2642
2746
  }
2643
2747
  }
@@ -2817,13 +2921,10 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2817
2921
  this.state.frameCache.clear();
2818
2922
  this.state.renderIssues.clear();
2819
2923
  this.state.pendingRenders.clear();
2820
- if (this.state.useWorker && this.state.workerReady) {
2821
- sendToWorker({ type: 'disposeDvb', sessionId: this.state.sessionId }).catch(() => { });
2822
- }
2924
+ this.releaseWorkerSession(this.state);
2823
2925
  this.dvbParser?.dispose();
2824
2926
  this.dvbParser = null;
2825
2927
  this.endTimestamps = new Float64Array(0);
2826
- this.state.sessionId = null;
2827
2928
  }
2828
2929
  }
2829
2930
  /**