libbitsub 1.12.1 → 1.13.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.
@@ -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() {
@@ -498,8 +529,6 @@ class BaseVideoSubtitleRenderer {
498
529
  }
499
530
  this.useWorkerOffscreen = true;
500
531
  this.emitRendererBackend('worker-offscreen');
501
- this.lastRenderedIndex = -1;
502
- this.lastRenderedTime = -1;
503
532
  await sendToWorker({
504
533
  type: 'resizeOffscreenCanvas',
505
534
  sessionId,
@@ -518,6 +547,8 @@ class BaseVideoSubtitleRenderer {
518
547
  details: { reason: error instanceof Error ? error.message : String(error) }
519
548
  }));
520
549
  }
550
+ if (this.useWorkerOffscreen)
551
+ this.reconcileBackendPresentation();
521
552
  }
522
553
  recreateCanvasForMainThreadFallback() {
523
554
  this.removeOwnedCanvas();
@@ -600,68 +631,112 @@ class BaseVideoSubtitleRenderer {
600
631
  }
601
632
  /** Initialize WebGPU renderer. */
602
633
  async initWebGPU(allowBackendFallback = true) {
634
+ if (this.disposed)
635
+ return;
636
+ let renderer = null;
603
637
  try {
604
- this.webgpuRenderer = new WebGPURenderer();
605
- await this.webgpuRenderer.init();
606
- if (!this.canvas)
638
+ renderer = new WebGPURenderer();
639
+ this.webgpuRenderer = renderer;
640
+ await renderer.init();
641
+ if (this.disposed || !this.canvas) {
642
+ renderer.destroy();
607
643
  return;
644
+ }
608
645
  const bounds = this.getVideoContentBounds();
609
646
  const pixelRatio = this.getDevicePixelRatio();
610
647
  const width = Math.max(1, bounds.width * pixelRatio);
611
648
  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');
649
+ await renderer.setCanvas(this.canvas, width, height);
650
+ if (this.disposed) {
651
+ renderer.destroy();
652
+ return;
653
+ }
615
654
  }
616
655
  catch (error) {
617
- this.webgpuRenderer?.destroy();
656
+ renderer?.destroy();
657
+ if (this.disposed)
658
+ return;
618
659
  this.webgpuRenderer = null;
619
660
  this.useWebGPU = false;
620
661
  this.onWebGPUFallback?.();
662
+ if (this.disposed)
663
+ return;
621
664
  if (allowBackendFallback && isWebGL2Supported()) {
622
665
  void this.initWebGL2();
623
666
  }
624
667
  else {
625
668
  this.preferWorkerOffscreenOrCanvas2D();
626
669
  }
670
+ return;
627
671
  }
672
+ this.useWebGPU = true;
673
+ this.emitRendererBackend('webgpu');
674
+ this.reconcileBackendPresentation();
628
675
  }
629
676
  /** Initialize WebGL2 renderer. */
630
677
  async initWebGL2(allowBackendFallback = true) {
678
+ if (this.disposed)
679
+ return;
680
+ let renderer = null;
631
681
  try {
632
- this.webgl2Renderer = new WebGL2Renderer();
633
- await this.webgl2Renderer.init();
634
- if (!this.canvas)
682
+ renderer = new WebGL2Renderer();
683
+ this.webgl2Renderer = renderer;
684
+ await renderer.init();
685
+ if (this.disposed || !this.canvas) {
686
+ renderer.destroy();
635
687
  return;
688
+ }
636
689
  const bounds = this.getVideoContentBounds();
637
690
  const pixelRatio = this.getDevicePixelRatio();
638
691
  const width = Math.max(1, bounds.width * pixelRatio);
639
692
  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');
693
+ await renderer.setCanvas(this.canvas, width, height);
694
+ if (this.disposed) {
695
+ renderer.destroy();
696
+ return;
697
+ }
643
698
  }
644
699
  catch (error) {
645
- this.webgl2Renderer?.destroy();
700
+ renderer?.destroy();
701
+ if (this.disposed)
702
+ return;
646
703
  this.webgl2Renderer = null;
647
704
  this.useWebGL2 = false;
648
705
  this.onWebGL2Fallback?.();
706
+ if (this.disposed)
707
+ return;
649
708
  if (allowBackendFallback) {
650
709
  this.preferWorkerOffscreenOrCanvas2D();
651
710
  }
652
711
  else {
653
712
  this.initCanvas2D();
654
713
  }
714
+ return;
655
715
  }
716
+ this.useWebGL2 = true;
717
+ this.emitRendererBackend('webgl2');
718
+ this.reconcileBackendPresentation();
656
719
  }
657
720
  /** Initialize Canvas2D renderer. */
658
721
  initCanvas2D() {
659
- if (!this.canvas)
722
+ if (this.disposed || !this.canvas)
660
723
  return;
661
724
  this.ctx = this.canvas.getContext('2d');
662
725
  this.useWebGPU = false;
663
726
  this.useWebGL2 = false;
664
727
  this.emitRendererBackend('canvas2d');
728
+ this.reconcileBackendPresentation();
729
+ }
730
+ /** A cue decoded before backend startup has not yet reached the canvas. */
731
+ reconcileBackendPresentation() {
732
+ if (this.disposed)
733
+ return;
734
+ this.invalidatePresentation();
735
+ this.lastRenderedIndex = -2;
736
+ this.lastRenderedTime = -1;
737
+ // Loading creates the temporary drawing canvas before starting presentation.
738
+ if (this.frameScheduler)
739
+ this.renderPausedFrame();
665
740
  }
666
741
  /** Called when video seeks. */
667
742
  onSeek() { }
@@ -779,6 +854,13 @@ class BaseVideoSubtitleRenderer {
779
854
  return;
780
855
  this.renderSynchronizedFrame({ mediaTime: this.video.currentTime, presentedFrames: null });
781
856
  }
857
+ /**
858
+ * Render the cue for a media time synchronously, whether or not the video is paused.
859
+ * Hosts call this from their own presented-frame callback so a cached cue paints on that frame.
860
+ */
861
+ renderAtMediaTime(mediaTime) {
862
+ this.renderSynchronizedFrame({ mediaTime, presentedFrames: null });
863
+ }
782
864
  renderSynchronizedFrame(tick) {
783
865
  if (this.disposed || !this.isLoaded)
784
866
  return;
@@ -853,7 +935,7 @@ class BaseVideoSubtitleRenderer {
853
935
  if (this.findCurrentIndex(this.getCurrentSynchronizationTime()) !== index)
854
936
  return;
855
937
  this.lastRenderedIndex = -1;
856
- this.renderPausedFrame();
938
+ this.renderAtMediaTime(this.getCurrentSynchronizationTime() - this.timeOffset);
857
939
  }, () => { });
858
940
  }
859
941
  recordRenderPerformance(renderTime, capturedAt, countAsDropped = true) {
@@ -904,12 +986,17 @@ class BaseVideoSubtitleRenderer {
904
986
  // If data is undefined, it means async loading is in progress
905
987
  // Keep showing the last frame only while waiting for async data
906
988
  // Note: null means "loaded but empty" (clear screen), undefined means "still loading"
989
+ let stalePending = false;
907
990
  if (data === undefined && this.lastRenderedData !== null && index >= 0) {
908
991
  // Check if this index has a pending render (truly async loading)
909
992
  // If not pending, it means the render returned no data immediately
910
993
  if (this.isPendingRender(index)) {
911
- // Don't clear - keep showing the last frame while loading
912
- return { status: 'pending', data: null, warning: null };
994
+ if (!this.clearStaleWhilePending) {
995
+ // Don't clear - keep showing the last frame while loading
996
+ return { status: 'pending', data: null, warning: null };
997
+ }
998
+ // The previous cue has ended: clear it below instead of holding it
999
+ stalePending = true;
913
1000
  }
914
1001
  }
915
1002
  const renderIssue = index >= 0 ? (this.getWorkerRendererState().renderIssues.get(index) ?? null) : null;
@@ -927,6 +1014,9 @@ class BaseVideoSubtitleRenderer {
927
1014
  if (index < 0) {
928
1015
  return { status: 'cleared', data: null, warning: null };
929
1016
  }
1017
+ if (stalePending) {
1018
+ return { status: 'pending', data: null, warning: null };
1019
+ }
930
1020
  if (warning) {
931
1021
  return { status: 'failed', data: data ?? null, warning };
932
1022
  }
@@ -1141,6 +1231,7 @@ class BaseVideoSubtitleRenderer {
1141
1231
  /** Dispose of all resources. */
1142
1232
  dispose() {
1143
1233
  this.disposed = true;
1234
+ this.lifecycle.abort();
1144
1235
  this.invalidatePresentation();
1145
1236
  this.frameScheduler?.stop();
1146
1237
  this.frameScheduler = null;
@@ -1222,10 +1313,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1222
1313
  return;
1223
1314
  }
1224
1315
  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
- });
1316
+ const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(this.subUrl, this.fetchOptions(this.rangeRequests, (progress) => this.emitLoadProgress('pgs', progress, this.state.timestamps.length)));
1229
1317
  this.emitLoadProgress('pgs', { loaded: data.byteLength, total: total ?? data.byteLength, ratio: 1, rangeSupported, strategy }, 0);
1230
1318
  await this.loadPgsBuffer(data, false);
1231
1319
  this.onLoaded?.();
@@ -1235,11 +1323,19 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1235
1323
  this.onLoaded?.();
1236
1324
  }
1237
1325
  catch (error) {
1326
+ if (this.disposed)
1327
+ return;
1238
1328
  const resolvedError = normalizeSubtitleError(error, { format: 'pgs' });
1239
1329
  this.emitEvent({ type: 'error', format: 'pgs', error: resolvedError });
1240
1330
  this.onError?.(resolvedError);
1241
1331
  }
1242
1332
  }
1333
+ /** Resolve an append response, which may carry only new timestamps, to the full list. */
1334
+ mergePgsTimestamps(response) {
1335
+ if (response.timestampBase === undefined)
1336
+ return response.timestamps;
1337
+ return spliceTimestamps(this.state.timestamps, response.timestampBase, response.timestamps);
1338
+ }
1243
1339
  applyPgsIndexState(metadata, timestamps, partial, usingWorker) {
1244
1340
  this.state.metadata = metadata;
1245
1341
  this.state.timestamps = timestamps;
@@ -1272,7 +1368,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1272
1368
  }
1273
1369
  catch (workerError) {
1274
1370
  this.state.useWorker = false;
1275
- this.state.workerReady = false;
1371
+ this.releaseWorkerSession(this.state);
1276
1372
  this.state.sessionId = null;
1277
1373
  this.emitWorkerState(false, false, null, true);
1278
1374
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'PGS worker initialization failed, falling back to main-thread rendering.', {
@@ -1323,12 +1419,13 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1323
1419
  if (response.type !== 'pgsProgress')
1324
1420
  throw new Error('Unexpected PGS worker response');
1325
1421
  added = response.added;
1422
+ const timestamps = this.mergePgsTimestamps(response);
1326
1423
  if (added > 0) {
1327
- this.applyPgsIndexState(response.metadata, response.timestamps, true, true);
1424
+ this.applyPgsIndexState(response.metadata, timestamps, true, true);
1328
1425
  }
1329
1426
  else {
1330
1427
  this.state.metadata = response.metadata;
1331
- this.state.timestamps = response.timestamps;
1428
+ this.state.timestamps = timestamps;
1332
1429
  }
1333
1430
  }
1334
1431
  else {
@@ -1426,6 +1523,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1426
1523
  catch (workerError) {
1427
1524
  this.state.useWorker = false;
1428
1525
  this.emitWorkerState(false, false, this.state.sessionId, true);
1526
+ this.releaseWorkerSession(this.state);
1429
1527
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'PGS worker initialization failed, falling back to main-thread rendering.', {
1430
1528
  format: 'pgs',
1431
1529
  details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
@@ -1451,6 +1549,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1451
1549
  this.state.useWorker = false;
1452
1550
  usedWorker = false;
1453
1551
  this.emitWorkerState(false, false, this.state.sessionId, true);
1552
+ this.releaseWorkerSession(this.state);
1454
1553
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'PGS worker initialization failed, falling back to main-thread rendering.', {
1455
1554
  format: 'pgs',
1456
1555
  details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
@@ -1463,10 +1562,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1463
1562
  this.pgsParser.reset();
1464
1563
  }
1465
1564
  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) => {
1565
+ 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
1566
  if (chunk.byteLength === 0)
1471
1567
  return;
1472
1568
  if (usedWorker && this.state.sessionId) {
@@ -1477,12 +1573,13 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1477
1573
  data: transferable
1478
1574
  });
1479
1575
  if (response.type === 'pgsProgress') {
1576
+ const timestamps = this.mergePgsTimestamps(response);
1480
1577
  if (response.added > 0 || !indexedOnce) {
1481
- this.applyPgsIndexState(response.metadata, response.timestamps, true, true);
1578
+ this.applyPgsIndexState(response.metadata, timestamps, true, true);
1482
1579
  indexedOnce = true;
1483
1580
  }
1484
1581
  else {
1485
- this.state.timestamps = response.timestamps;
1582
+ this.state.timestamps = timestamps;
1486
1583
  this.state.metadata = response.metadata;
1487
1584
  }
1488
1585
  }
@@ -1536,12 +1633,15 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1536
1633
  if (usedWorker) {
1537
1634
  this.state.useWorker = false;
1538
1635
  this.emitWorkerState(false, false, this.state.sessionId, true);
1636
+ this.releaseWorkerSession(this.state);
1539
1637
  }
1638
+ if (this.disposed)
1639
+ throw error;
1540
1640
  this.emitWarning(createSubtitleWarning('RANGE_FALLBACK', 'Progressive PGS load failed; retrying with a full buffer fetch.', {
1541
1641
  format: 'pgs',
1542
1642
  details: { reason: error instanceof Error ? error.message : String(error) }
1543
1643
  }));
1544
- const { data } = await fetchSubtitleAsset(url, { preferRange: this.rangeRequests });
1644
+ const { data } = await fetchSubtitleAsset(url, this.fetchOptions(this.rangeRequests));
1545
1645
  await this.loadPgsBuffer(data, false);
1546
1646
  }
1547
1647
  }
@@ -1718,12 +1818,9 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
1718
1818
  this.state.frameCache.clear();
1719
1819
  this.state.renderIssues.clear();
1720
1820
  this.state.pendingRenders.clear();
1721
- if (this.state.useWorker && this.state.workerReady) {
1722
- sendToWorker({ type: 'disposePgs', sessionId: this.state.sessionId }).catch(() => { });
1723
- }
1821
+ this.releaseWorkerSession(this.state);
1724
1822
  this.pgsParser?.dispose();
1725
1823
  this.pgsParser = null;
1726
- this.state.sessionId = null;
1727
1824
  }
1728
1825
  }
1729
1826
  /**
@@ -1739,10 +1836,8 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1739
1836
  onLoading;
1740
1837
  onLoaded;
1741
1838
  onError;
1742
- // Async index lookup state
1743
- cachedIndex = -1;
1744
- cachedIndexTime = -1;
1745
- pendingIndexLookup = null;
1839
+ // Cue end times (ms) for the main-thread index lookup in worker mode; starts live in state.timestamps
1840
+ cueEnds = null;
1746
1841
  constructor(options) {
1747
1842
  super(options, 'vobsub');
1748
1843
  this.idxUrl =
@@ -1753,6 +1848,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1753
1848
  this.onLoading = options.onLoading;
1754
1849
  this.onLoaded = options.onLoaded;
1755
1850
  this.onError = options.onError;
1851
+ this.clearStaleWhilePending = true;
1756
1852
  applyCacheLimit(this.state, this.cacheLimit);
1757
1853
  this.startInit();
1758
1854
  }
@@ -1783,6 +1879,8 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1783
1879
  this.onLoaded?.();
1784
1880
  }
1785
1881
  catch (error) {
1882
+ if (this.disposed)
1883
+ return;
1786
1884
  const resolvedError = normalizeSubtitleError(error, { format: 'vobsub' });
1787
1885
  this.emitEvent({ type: 'error', format: 'vobsub', error: resolvedError });
1788
1886
  this.onError?.(resolvedError);
@@ -1813,6 +1911,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1813
1911
  catch (workerError) {
1814
1912
  this.state.useWorker = false;
1815
1913
  this.emitWorkerState(false, false, this.state.sessionId, true);
1914
+ this.releaseWorkerSession(this.state);
1816
1915
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'VobSub worker initialization failed, falling back to main-thread rendering.', {
1817
1916
  format: 'vobsub',
1818
1917
  details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
@@ -1845,6 +1944,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1845
1944
  this.state.workerReady = true;
1846
1945
  this.state.metadata = loadResponse.metadata;
1847
1946
  this.state.timestamps = loadResponse.timestamps;
1947
+ await this.loadCueTable();
1848
1948
  this.isLoaded = true;
1849
1949
  this.setParserMetadata(loadResponse.metadata);
1850
1950
  this.emitIndexed('vobsub', loadResponse.metadata, false);
@@ -1861,6 +1961,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1861
1961
  }
1862
1962
  this.state.useWorker = false;
1863
1963
  this.emitWorkerState(false, false, this.state.sessionId, true);
1964
+ this.releaseWorkerSession(this.state);
1864
1965
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'VobSub worker load failed, falling back to main-thread rendering.', {
1865
1966
  format: 'vobsub',
1866
1967
  details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
@@ -1877,10 +1978,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1877
1978
  if (!this.subUrl) {
1878
1979
  throw createSubtitleDiagnosticError('MISSING_INPUT', 'No SUB content or URL provided.', { format: 'vobsub' });
1879
1980
  }
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
- });
1981
+ 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
1982
  this.emitLoadProgress('vobsub', {
1885
1983
  loaded: data.byteLength,
1886
1984
  total: total ?? data.byteLength,
@@ -1896,9 +1994,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1896
1994
  if (!this.idxUrl) {
1897
1995
  throw createSubtitleDiagnosticError('MISSING_INPUT', 'No IDX content or URL provided.', { format: 'vobsub' });
1898
1996
  }
1899
- idxData = await fetchSubtitleText(this.idxUrl, {
1900
- onProgress: (progress) => this.emitLoadProgress('vobsub', progress, 0)
1901
- });
1997
+ idxData = await fetchSubtitleText(this.idxUrl, this.fetchOptions(false, (progress) => this.emitLoadProgress('vobsub', progress, 0)));
1902
1998
  }
1903
1999
  let usedWorker = await this.ensureVobSubWorkerSession();
1904
2000
  if (usedWorker && this.state.sessionId) {
@@ -1929,6 +2025,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1929
2025
  usedWorker = false;
1930
2026
  this.state.useWorker = false;
1931
2027
  this.emitWorkerState(false, false, this.state.sessionId, true);
2028
+ this.releaseWorkerSession(this.state);
1932
2029
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'VobSub worker IDX load failed, falling back to main-thread rendering.', {
1933
2030
  format: 'vobsub',
1934
2031
  details: { reason: error instanceof Error ? error.message : String(error) }
@@ -1953,10 +2050,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1953
2050
  if (!this.subUrl) {
1954
2051
  throw createSubtitleDiagnosticError('MISSING_INPUT', 'No SUB content or URL provided.', { format: 'vobsub' });
1955
2052
  }
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
- });
2053
+ const fetched = await fetchSubtitleAsset(this.subUrl, this.fetchOptions(this.rangeRequests && this.streamingLoad, (progress) => this.emitLoadProgress('vobsub', progress, this.state.timestamps.length)));
1960
2054
  subData = fetched.data;
1961
2055
  this.emitLoadProgress('vobsub', {
1962
2056
  loaded: subData.byteLength,
@@ -1976,6 +2070,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1976
2070
  if (attachResponse.type === 'vobSubProgress') {
1977
2071
  this.applyVobSubIndexState(attachResponse.metadata, attachResponse.timestamps, false, true, true);
1978
2072
  this.state.workerReady = true;
2073
+ await this.loadCueTable();
1979
2074
  this.isLoaded = true;
1980
2075
  this.emitWorkerState(true, true, this.state.sessionId);
1981
2076
  return;
@@ -2045,50 +2140,53 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
2045
2140
  }
2046
2141
  findCurrentIndex(time) {
2047
2142
  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;
2143
+ return this.findIndexInCueTable(time);
2089
2144
  }
2090
2145
  return this.vobsubParser?.findIndexAtTimestamp(time) ?? -1;
2091
2146
  }
2147
+ /** Synchronous lookup against the worker's cue table; mirrors the core parser's find_index_at_timestamp. */
2148
+ findIndexInCueTable(time) {
2149
+ const starts = this.state.timestamps;
2150
+ const ends = this.cueEnds;
2151
+ if (!ends || starts.length === 0)
2152
+ return -1;
2153
+ const timeMs = Math.max(0, Math.floor(time * 1000));
2154
+ let low = 0;
2155
+ let high = starts.length;
2156
+ while (low < high) {
2157
+ const mid = low + ((high - low) >> 1);
2158
+ if (starts[mid] <= timeMs)
2159
+ low = mid + 1;
2160
+ else
2161
+ high = mid;
2162
+ }
2163
+ const index = low > 0 ? low - 1 : 0;
2164
+ if (timeMs < starts[index] || timeMs >= ends[index])
2165
+ return -1;
2166
+ return index;
2167
+ }
2168
+ /** Fetch cue end times once, after the worker holds the SUB data. Falls back to next-start ends. */
2169
+ async loadCueTable() {
2170
+ const starts = this.state.timestamps;
2171
+ let ends = null;
2172
+ try {
2173
+ const response = await sendToWorker({ type: 'getVobSubTimestamps', sessionId: this.state.sessionId });
2174
+ const received = response.endTimestamps;
2175
+ if (received && received.length === starts.length)
2176
+ ends = received;
2177
+ }
2178
+ catch {
2179
+ /* fall back below */
2180
+ }
2181
+ if (!ends) {
2182
+ ends = new Float64Array(starts.length);
2183
+ for (let i = 0; i < starts.length; i++)
2184
+ ends[i] = starts[i + 1] ?? starts[i] + 5000;
2185
+ }
2186
+ this.cueEnds = ends;
2187
+ this.lastRenderedIndex = -1;
2188
+ this.renderPausedFrame();
2189
+ }
2092
2190
  renderAtIndex(index) {
2093
2191
  if (this.state.frameCache.has(index)) {
2094
2192
  return this.state.frameCache.get(index) ?? undefined;
@@ -2159,10 +2257,6 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
2159
2257
  this.state.frameCache.clear();
2160
2258
  this.state.renderIssues.clear();
2161
2259
  this.state.pendingRenders.clear();
2162
- // Clear cached index lookup on seek
2163
- this.cachedIndex = -1;
2164
- this.cachedIndexTime = -1;
2165
- this.pendingIndexLookup = null;
2166
2260
  if (this.state.useWorker && this.state.workerReady) {
2167
2261
  sendToWorker({ type: 'clearVobSubCache', sessionId: this.state.sessionId }).catch(() => { });
2168
2262
  }
@@ -2179,9 +2273,6 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
2179
2273
  this.state.renderIssues.clear();
2180
2274
  this.state.pendingRenders.clear();
2181
2275
  this.clearOffscreenFrameMetadata();
2182
- this.cachedIndex = -1;
2183
- this.cachedIndexTime = -1;
2184
- this.pendingIndexLookup = null;
2185
2276
  this.lastRenderedIndex = -1;
2186
2277
  if (this.state.useWorker && this.state.workerReady) {
2187
2278
  sendToWorker({ type: 'clearVobSubCache', sessionId: this.state.sessionId }).catch(() => { });
@@ -2270,12 +2361,9 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
2270
2361
  this.state.frameCache.clear();
2271
2362
  this.state.renderIssues.clear();
2272
2363
  this.state.pendingRenders.clear();
2273
- if (this.state.useWorker && this.state.workerReady) {
2274
- sendToWorker({ type: 'disposeVobSub', sessionId: this.state.sessionId }).catch(() => { });
2275
- }
2364
+ this.releaseWorkerSession(this.state);
2276
2365
  this.vobsubParser?.dispose();
2277
2366
  this.vobsubParser = null;
2278
- this.state.sessionId = null;
2279
2367
  }
2280
2368
  }
2281
2369
  /** High-level DVB overlay renderer bound to a video element. */
@@ -2313,10 +2401,7 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2313
2401
  return;
2314
2402
  }
2315
2403
  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
- });
2404
+ const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(this.subUrl, this.fetchOptions(this.rangeRequests, (progress) => this.emitLoadProgress('dvb', progress, this.state.timestamps.length)));
2320
2405
  this.emitLoadProgress('dvb', { loaded: data.byteLength, total: total ?? data.byteLength, ratio: 1, rangeSupported, strategy }, 0);
2321
2406
  await this.loadDvbBuffer(data, false);
2322
2407
  this.onLoaded?.();
@@ -2326,11 +2411,23 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2326
2411
  this.onLoaded?.();
2327
2412
  }
2328
2413
  catch (error) {
2414
+ if (this.disposed)
2415
+ return;
2329
2416
  const resolvedError = normalizeSubtitleError(error, { format: 'dvb' });
2330
2417
  this.emitEvent({ type: 'error', format: 'dvb', error: resolvedError });
2331
2418
  this.onError?.(resolvedError);
2332
2419
  }
2333
2420
  }
2421
+ /** Resolve an append response, which may carry only changed entries, to the full lists. */
2422
+ mergeDvbTimestamps(response) {
2423
+ const base = response.timestampBase;
2424
+ if (base === undefined)
2425
+ return { timestamps: response.timestamps, endTimestamps: response.endTimestamps };
2426
+ return {
2427
+ timestamps: spliceTimestamps(this.state.timestamps, base, response.timestamps),
2428
+ endTimestamps: spliceTimestamps(this.endTimestamps, base, response.endTimestamps)
2429
+ };
2430
+ }
2334
2431
  applyDvbIndexState(metadata, timestamps, endTimestamps, partial, usingWorker) {
2335
2432
  this.state.metadata = metadata;
2336
2433
  this.state.timestamps = timestamps;
@@ -2364,7 +2461,7 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2364
2461
  }
2365
2462
  catch (workerError) {
2366
2463
  this.state.useWorker = false;
2367
- this.state.workerReady = false;
2464
+ this.releaseWorkerSession(this.state);
2368
2465
  this.state.sessionId = null;
2369
2466
  this.emitWorkerState(false, false, null, true);
2370
2467
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'DVB worker initialization failed, falling back to main-thread rendering.', {
@@ -2415,13 +2512,14 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2415
2512
  if (response.type !== 'dvbProgress')
2416
2513
  throw new Error('Unexpected DVB worker response');
2417
2514
  added = response.added;
2515
+ const { timestamps, endTimestamps } = this.mergeDvbTimestamps(response);
2418
2516
  if (added > 0) {
2419
- this.applyDvbIndexState(response.metadata, response.timestamps, response.endTimestamps, true, true);
2517
+ this.applyDvbIndexState(response.metadata, timestamps, endTimestamps, true, true);
2420
2518
  }
2421
2519
  else {
2422
2520
  this.state.metadata = response.metadata;
2423
- this.state.timestamps = response.timestamps;
2424
- this.endTimestamps = response.endTimestamps;
2521
+ this.state.timestamps = timestamps;
2522
+ this.endTimestamps = endTimestamps;
2425
2523
  }
2426
2524
  }
2427
2525
  else {
@@ -2520,6 +2618,7 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2520
2618
  catch (workerError) {
2521
2619
  this.state.useWorker = false;
2522
2620
  this.emitWorkerState(false, false, this.state.sessionId, true);
2621
+ this.releaseWorkerSession(this.state);
2523
2622
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'DVB worker initialization failed, falling back to main-thread rendering.', {
2524
2623
  format: 'dvb',
2525
2624
  details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
@@ -2545,6 +2644,7 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2545
2644
  this.state.useWorker = false;
2546
2645
  usedWorker = false;
2547
2646
  this.emitWorkerState(false, false, this.state.sessionId, true);
2647
+ this.releaseWorkerSession(this.state);
2548
2648
  this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'DVB worker initialization failed, falling back to main-thread rendering.', {
2549
2649
  format: 'dvb',
2550
2650
  details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
@@ -2557,10 +2657,7 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2557
2657
  this.dvbParser.reset();
2558
2658
  }
2559
2659
  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) => {
2660
+ 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
2661
  if (chunk.byteLength === 0)
2565
2662
  return;
2566
2663
  if (usedWorker && this.state.sessionId) {
@@ -2571,13 +2668,14 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2571
2668
  data: transferable
2572
2669
  });
2573
2670
  if (response.type === 'dvbProgress') {
2671
+ const { timestamps, endTimestamps } = this.mergeDvbTimestamps(response);
2574
2672
  if (response.added > 0 || !indexedOnce) {
2575
- this.applyDvbIndexState(response.metadata, response.timestamps, response.endTimestamps, true, true);
2673
+ this.applyDvbIndexState(response.metadata, timestamps, endTimestamps, true, true);
2576
2674
  indexedOnce = true;
2577
2675
  }
2578
2676
  else {
2579
- this.state.timestamps = response.timestamps;
2580
- this.endTimestamps = response.endTimestamps;
2677
+ this.state.timestamps = timestamps;
2678
+ this.endTimestamps = endTimestamps;
2581
2679
  this.state.metadata = response.metadata;
2582
2680
  }
2583
2681
  }
@@ -2632,12 +2730,15 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2632
2730
  if (usedWorker) {
2633
2731
  this.state.useWorker = false;
2634
2732
  this.emitWorkerState(false, false, this.state.sessionId, true);
2733
+ this.releaseWorkerSession(this.state);
2635
2734
  }
2735
+ if (this.disposed)
2736
+ throw error;
2636
2737
  this.emitWarning(createSubtitleWarning('RANGE_FALLBACK', 'Progressive DVB load failed; retrying with a full buffer fetch.', {
2637
2738
  format: 'dvb',
2638
2739
  details: { reason: error instanceof Error ? error.message : String(error) }
2639
2740
  }));
2640
- const { data } = await fetchSubtitleAsset(url, { preferRange: this.rangeRequests });
2741
+ const { data } = await fetchSubtitleAsset(url, this.fetchOptions(this.rangeRequests));
2641
2742
  await this.loadDvbBuffer(data, false);
2642
2743
  }
2643
2744
  }
@@ -2817,13 +2918,10 @@ export class DvbRenderer extends BaseVideoSubtitleRenderer {
2817
2918
  this.state.frameCache.clear();
2818
2919
  this.state.renderIssues.clear();
2819
2920
  this.state.pendingRenders.clear();
2820
- if (this.state.useWorker && this.state.workerReady) {
2821
- sendToWorker({ type: 'disposeDvb', sessionId: this.state.sessionId }).catch(() => { });
2822
- }
2921
+ this.releaseWorkerSession(this.state);
2823
2922
  this.dvbParser?.dispose();
2824
2923
  this.dvbParser = null;
2825
2924
  this.endTimestamps = new Float64Array(0);
2826
- this.state.sessionId = null;
2827
2925
  }
2828
2926
  }
2829
2927
  /**