libbitsub 1.10.1 → 1.11.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 (91) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +186 -36
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/integrations/hlsjs.d.ts +45 -0
  8. package/dist/integrations/hlsjs.d.ts.map +1 -0
  9. package/dist/integrations/hlsjs.js +121 -0
  10. package/dist/integrations/hlsjs.js.map +1 -0
  11. package/dist/integrations/index.d.ts +11 -0
  12. package/dist/integrations/index.d.ts.map +1 -0
  13. package/dist/integrations/index.js +11 -0
  14. package/dist/integrations/index.js.map +1 -0
  15. package/dist/integrations/react.d.ts +55 -0
  16. package/dist/integrations/react.d.ts.map +1 -0
  17. package/dist/integrations/react.js +127 -0
  18. package/dist/integrations/react.js.map +1 -0
  19. package/dist/integrations/shaka.d.ts +38 -0
  20. package/dist/integrations/shaka.d.ts.map +1 -0
  21. package/dist/integrations/shaka.js +71 -0
  22. package/dist/integrations/shaka.js.map +1 -0
  23. package/dist/integrations/shared.d.ts +41 -0
  24. package/dist/integrations/shared.d.ts.map +1 -0
  25. package/dist/integrations/shared.js +123 -0
  26. package/dist/integrations/shared.js.map +1 -0
  27. package/dist/integrations/videojs.d.ts +85 -0
  28. package/dist/integrations/videojs.d.ts.map +1 -0
  29. package/dist/integrations/videojs.js +111 -0
  30. package/dist/integrations/videojs.js.map +1 -0
  31. package/dist/ts/diagnostics.d.ts.map +1 -1
  32. package/dist/ts/diagnostics.js.map +1 -1
  33. package/dist/ts/frame-export.d.ts.map +1 -1
  34. package/dist/ts/frame-export.js +4 -4
  35. package/dist/ts/frame-export.js.map +1 -1
  36. package/dist/ts/parsers.d.ts +7 -6
  37. package/dist/ts/parsers.d.ts.map +1 -1
  38. package/dist/ts/parsers.js +76 -13
  39. package/dist/ts/parsers.js.map +1 -1
  40. package/dist/ts/range-loader.d.ts +31 -0
  41. package/dist/ts/range-loader.d.ts.map +1 -0
  42. package/dist/ts/range-loader.js +220 -0
  43. package/dist/ts/range-loader.js.map +1 -0
  44. package/dist/ts/renderers.d.ts +14 -0
  45. package/dist/ts/renderers.d.ts.map +1 -1
  46. package/dist/ts/renderers.js +445 -162
  47. package/dist/ts/renderers.js.map +1 -1
  48. package/dist/ts/types.d.ts +51 -1
  49. package/dist/ts/types.d.ts.map +1 -1
  50. package/dist/ts/utils.d.ts.map +1 -1
  51. package/dist/ts/utils.js.map +1 -1
  52. package/dist/ts/wasm.d.ts +2 -0
  53. package/dist/ts/wasm.d.ts.map +1 -1
  54. package/dist/ts/wasm.js +12 -0
  55. package/dist/ts/wasm.js.map +1 -1
  56. package/dist/ts/webgl2-renderer.d.ts +9 -0
  57. package/dist/ts/webgl2-renderer.d.ts.map +1 -1
  58. package/dist/ts/webgl2-renderer.js +34 -0
  59. package/dist/ts/webgl2-renderer.js.map +1 -1
  60. package/dist/ts/webgpu-renderer.d.ts +9 -0
  61. package/dist/ts/webgpu-renderer.d.ts.map +1 -1
  62. package/dist/ts/webgpu-renderer.js +70 -2
  63. package/dist/ts/webgpu-renderer.js.map +1 -1
  64. package/dist/ts/worker.d.ts +11 -0
  65. package/dist/ts/worker.d.ts.map +1 -1
  66. package/dist/ts/worker.js +235 -160
  67. package/dist/ts/worker.js.map +1 -1
  68. package/dist/wrapper.d.ts +3 -1
  69. package/dist/wrapper.d.ts.map +1 -1
  70. package/dist/wrapper.js +2 -0
  71. package/dist/wrapper.js.map +1 -1
  72. package/examples/README.md +38 -0
  73. package/examples/hlsjs/README.md +53 -0
  74. package/examples/react/README.md +60 -0
  75. package/examples/shaka/README.md +61 -0
  76. package/examples/videojs/README.md +55 -0
  77. package/package.json +68 -12
  78. package/pkg/LICENSE +1 -0
  79. package/pkg/README.md +186 -36
  80. package/pkg/libbitsub.d.ts +14 -0
  81. package/pkg/libbitsub.js +50 -0
  82. package/pkg/libbitsub_bg.wasm +0 -0
  83. package/pkg/libbitsub_bg.wasm.d.ts +7 -0
  84. package/pkg/package.json +1 -1
  85. package/src/integrations/hlsjs.ts +154 -0
  86. package/src/integrations/index.ts +36 -0
  87. package/src/integrations/react.tsx +170 -0
  88. package/src/integrations/shaka.ts +92 -0
  89. package/src/integrations/shared.ts +170 -0
  90. package/src/integrations/videojs.ts +200 -0
  91. package/src/wrapper.ts +12 -0
@@ -2,7 +2,8 @@
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';
8
9
  import { binarySearchTimestamp, convertFrameData, createWorkerSessionId, createWorkerState, detectSubtitleFormat, getSubtitleBounds, isMksSource, setCacheLimit as applyCacheLimit, setCachedFrame } from './utils';
@@ -59,6 +60,8 @@ class BaseVideoSubtitleRenderer {
59
60
  cacheLimit = 24;
60
61
  prefetchBefore = 0;
61
62
  prefetchAfter = 0;
63
+ streamingLoad = true;
64
+ rangeRequests = true;
62
65
  onEvent;
63
66
  onWarning;
64
67
  currentRendererBackend = null;
@@ -98,6 +101,32 @@ class BaseVideoSubtitleRenderer {
98
101
  this.cacheLimit = Math.max(0, Math.floor(options.cacheLimit ?? 24));
99
102
  this.prefetchBefore = Math.max(0, Math.floor(options.prefetchWindow?.before ?? 0));
100
103
  this.prefetchAfter = Math.max(0, Math.floor(options.prefetchWindow?.after ?? 0));
104
+ this.streamingLoad = options.streamingLoad !== false;
105
+ this.rangeRequests = options.rangeRequests !== false;
106
+ }
107
+ emitLoadProgress(format, progress, indexedCues) {
108
+ this.emitEvent({
109
+ type: 'load-progress',
110
+ format,
111
+ loadedBytes: progress.loaded,
112
+ totalBytes: progress.total,
113
+ ratio: progress.ratio,
114
+ strategy: progress.strategy,
115
+ rangeSupported: progress.rangeSupported,
116
+ indexedCues
117
+ });
118
+ }
119
+ emitIndexed(format, metadata, partial) {
120
+ this.emitEvent({ type: 'indexed', format, metadata, partial });
121
+ }
122
+ memoryProgress(byteLength) {
123
+ return {
124
+ loaded: byteLength,
125
+ total: byteLength,
126
+ ratio: 1,
127
+ rangeSupported: false,
128
+ strategy: 'memory'
129
+ };
101
130
  }
102
131
  /** Get current display settings */
103
132
  getDisplaySettings() {
@@ -194,7 +223,11 @@ class BaseVideoSubtitleRenderer {
194
223
  /** Start initialization. */
195
224
  startInit() {
196
225
  this.init().catch((error) => {
197
- this.emitEvent({ type: 'error', format: this.format, error: normalizeSubtitleError(error, { format: this.format }) });
226
+ this.emitEvent({
227
+ type: 'error',
228
+ format: this.format,
229
+ error: normalizeSubtitleError(error, { format: this.format })
230
+ });
198
231
  });
199
232
  }
200
233
  /** Initialize the renderer. */
@@ -462,7 +495,8 @@ class BaseVideoSubtitleRenderer {
462
495
  });
463
496
  this.emitEvent({ type: 'stats', stats: this.getStats() });
464
497
  if (currentIndex >= 0 && (this.prefetchBefore > 0 || this.prefetchAfter > 0)) {
465
- const prefetch = this.prefetchAroundTime;
498
+ const prefetch = this
499
+ .prefetchAroundTime;
466
500
  prefetch?.call(this, currentTime).catch(() => { });
467
501
  }
468
502
  }
@@ -489,7 +523,7 @@ class BaseVideoSubtitleRenderer {
489
523
  return { status: 'pending', data: null, warning: null };
490
524
  }
491
525
  }
492
- const renderIssue = index >= 0 ? this.getWorkerRendererState().renderIssues.get(index) ?? null : null;
526
+ const renderIssue = index >= 0 ? (this.getWorkerRendererState().renderIssues.get(index) ?? null) : null;
493
527
  const warning = warningFromRenderIssue(renderIssue, { format: this.format, cueIndex: index });
494
528
  // Use best available renderer
495
529
  if (this.useWebGPU && this.webgpuRenderer) {
@@ -533,9 +567,7 @@ class BaseVideoSubtitleRenderer {
533
567
  let frameShiftX = 0;
534
568
  let frameShiftY = 0;
535
569
  if (aspectMode !== 'stretch') {
536
- const uniformScale = aspectMode === 'cover'
537
- ? Math.max(stretchScaleX, stretchScaleY)
538
- : Math.min(stretchScaleX, stretchScaleY);
570
+ const uniformScale = aspectMode === 'cover' ? Math.max(stretchScaleX, stretchScaleY) : Math.min(stretchScaleX, stretchScaleY);
539
571
  baseScaleX = uniformScale;
540
572
  baseScaleY = uniformScale;
541
573
  frameShiftX = (this.canvas.width - safeDataWidth * uniformScale) / 2;
@@ -701,77 +733,200 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
701
733
  try {
702
734
  this.emitEvent({ type: 'loading', format: 'pgs' });
703
735
  this.onLoading?.();
704
- let arrayBuffer;
705
736
  if (this.subContent) {
706
- arrayBuffer = this.subContent;
707
- }
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();
737
+ const data = new Uint8Array(this.subContent);
738
+ this.emitLoadProgress('pgs', this.memoryProgress(data.byteLength), 0);
739
+ await this.loadPgsBuffer(data, true);
740
+ this.onLoaded?.();
741
+ return;
713
742
  }
714
- else {
743
+ if (!this.subUrl) {
715
744
  throw new Error('No subtitle content or URL provided');
716
745
  }
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',
746
+ if (!this.streamingLoad) {
747
+ const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(this.subUrl, {
748
+ preferRange: this.rangeRequests,
749
+ onProgress: (progress) => this.emitLoadProgress('pgs', progress, this.state.timestamps.length)
750
+ });
751
+ this.emitLoadProgress('pgs', { loaded: data.byteLength, total: total ?? data.byteLength, ratio: 1, rangeSupported, strategy }, 0);
752
+ await this.loadPgsBuffer(data, false);
753
+ this.onLoaded?.();
754
+ return;
755
+ }
756
+ await this.loadPgsStreaming(this.subUrl);
757
+ this.onLoaded?.();
758
+ }
759
+ catch (error) {
760
+ const resolvedError = normalizeSubtitleError(error, { format: 'pgs' });
761
+ this.emitEvent({ type: 'error', format: 'pgs', error: resolvedError });
762
+ this.onError?.(resolvedError);
763
+ }
764
+ }
765
+ applyPgsIndexState(metadata, timestamps, partial, usingWorker) {
766
+ this.state.metadata = metadata;
767
+ this.state.timestamps = timestamps;
768
+ this.setParserMetadata(metadata);
769
+ this.emitIndexed('pgs', metadata, partial);
770
+ if (!this.isLoaded && timestamps.length > 0) {
771
+ this.isLoaded = true;
772
+ if (usingWorker) {
773
+ this.state.workerReady = true;
774
+ this.emitWorkerState(true, true, this.state.sessionId);
775
+ }
776
+ }
777
+ }
778
+ async loadPgsBuffer(data, preserveSource) {
779
+ if (this.state.useWorker) {
780
+ try {
781
+ this.state.sessionId = createWorkerSessionId();
782
+ await getOrCreateWorker();
783
+ this.emitWorkerState(true, false, this.state.sessionId);
784
+ const transferableData = createTransferableBuffer(data, preserveSource);
785
+ const loadResponse = await sendToWorker({
786
+ type: 'loadPgs',
787
+ sessionId: this.state.sessionId,
788
+ data: transferableData
789
+ });
790
+ if (loadResponse.type === 'pgsLoaded') {
791
+ this.state.workerReady = true;
792
+ this.state.metadata = loadResponse.metadata;
793
+ this.state.timestamps = loadResponse.timestamps;
794
+ this.isLoaded = true;
795
+ this.setParserMetadata(loadResponse.metadata);
796
+ this.emitIndexed('pgs', loadResponse.metadata, false);
797
+ this.emitWorkerState(true, true, this.state.sessionId);
798
+ return;
799
+ }
800
+ else if (loadResponse.type === 'error') {
801
+ throw new Error(loadResponse.message);
802
+ }
803
+ }
804
+ catch (workerError) {
805
+ this.state.useWorker = false;
806
+ this.emitWorkerState(false, false, this.state.sessionId, true);
807
+ this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'PGS worker initialization failed, falling back to main-thread rendering.', {
808
+ format: 'pgs',
809
+ details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
810
+ }));
811
+ }
812
+ }
813
+ await this.loadOnMainThread(data);
814
+ }
815
+ async loadPgsStreaming(url) {
816
+ let usedWorker = false;
817
+ let indexedOnce = false;
818
+ if (this.state.useWorker) {
819
+ try {
820
+ this.state.sessionId = createWorkerSessionId();
821
+ await getOrCreateWorker();
822
+ this.emitWorkerState(true, false, this.state.sessionId);
823
+ const begin = await sendToWorker({ type: 'beginPgs', sessionId: this.state.sessionId });
824
+ if (begin.type === 'error')
825
+ throw new Error(begin.message);
826
+ usedWorker = true;
827
+ }
828
+ catch (workerError) {
829
+ this.state.useWorker = false;
830
+ usedWorker = false;
831
+ this.emitWorkerState(false, false, this.state.sessionId, true);
832
+ this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'PGS worker initialization failed, falling back to main-thread rendering.', {
833
+ format: 'pgs',
834
+ details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
835
+ }));
836
+ }
837
+ }
838
+ if (!usedWorker) {
839
+ await this.yieldToMain();
840
+ this.pgsParser = new PgsParser({ debug: this.debug, onWarning: (warning) => this.emitWarning(warning) });
841
+ this.pgsParser.reset();
842
+ }
843
+ try {
844
+ const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(url, {
845
+ preferRange: this.rangeRequests,
846
+ onProgress: (progress) => this.emitLoadProgress('pgs', progress, this.state.timestamps.length)
847
+ }, async (chunk, progress) => {
848
+ if (chunk.byteLength === 0)
849
+ return;
850
+ if (usedWorker && this.state.sessionId) {
851
+ const transferable = createTransferableBuffer(chunk, true);
852
+ const response = await sendToWorker({
853
+ type: 'appendPgs',
726
854
  sessionId: this.state.sessionId,
727
- data: transferableData
855
+ data: transferable
728
856
  });
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
857
+ if (response.type === 'pgsProgress') {
858
+ if (response.added > 0 || !indexedOnce) {
859
+ this.applyPgsIndexState(response.metadata, response.timestamps, true, true);
860
+ indexedOnce = true;
861
+ }
862
+ else {
863
+ this.state.timestamps = response.timestamps;
864
+ this.state.metadata = response.metadata;
865
+ }
738
866
  }
739
- else if (loadResponse.type === 'error') {
740
- throw new Error(loadResponse.message);
867
+ else if (response.type === 'error') {
868
+ throw new Error(response.message);
741
869
  }
742
870
  }
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());
871
+ else if (this.pgsParser) {
872
+ const added = this.pgsParser.feed(chunk);
873
+ if (added > 0 || !indexedOnce) {
874
+ const metadata = this.pgsParser.getMetadata();
875
+ this.applyPgsIndexState(metadata, this.pgsParser.getTimestamps(), true, false);
876
+ indexedOnce = true;
755
877
  }
756
878
  }
879
+ this.emitLoadProgress('pgs', progress, this.state.timestamps.length);
880
+ });
881
+ if (usedWorker && this.state.sessionId) {
882
+ const finish = await sendToWorker({ type: 'finishPgs', sessionId: this.state.sessionId });
883
+ if (finish.type === 'pgsProgress') {
884
+ this.applyPgsIndexState(finish.metadata, finish.timestamps, false, true);
885
+ this.state.workerReady = true;
886
+ this.isLoaded = true;
887
+ this.emitWorkerState(true, true, this.state.sessionId);
888
+ }
889
+ else if (finish.type === 'error') {
890
+ throw new Error(finish.message);
891
+ }
757
892
  }
758
- // Main thread fallback - use idle callback to avoid blocking UI
759
- await this.loadOnMainThread(data);
760
- this.onLoaded?.();
893
+ else if (this.pgsParser) {
894
+ this.pgsParser.finishFeed();
895
+ const metadata = this.pgsParser.getMetadata();
896
+ this.state.timestamps = this.pgsParser.getTimestamps();
897
+ this.state.metadata = metadata;
898
+ this.isLoaded = true;
899
+ this.setParserMetadata(metadata);
900
+ this.emitIndexed('pgs', metadata, false);
901
+ if (metadata.cueCount === 0) {
902
+ this.state.renderIssues.set(-1, 'INVALID_SUBTITLE_DATA');
903
+ }
904
+ }
905
+ this.emitLoadProgress('pgs', {
906
+ loaded: data.byteLength,
907
+ total: total ?? data.byteLength,
908
+ ratio: 1,
909
+ rangeSupported,
910
+ strategy: strategy
911
+ }, this.state.timestamps.length);
761
912
  }
762
913
  catch (error) {
763
- const resolvedError = normalizeSubtitleError(error, { format: 'pgs' });
764
- this.emitEvent({ type: 'error', format: 'pgs', error: resolvedError });
765
- this.onError?.(resolvedError);
914
+ if (usedWorker) {
915
+ this.state.useWorker = false;
916
+ this.emitWorkerState(false, false, this.state.sessionId, true);
917
+ }
918
+ this.emitWarning(createSubtitleWarning('RANGE_FALLBACK', 'Progressive PGS load failed; retrying with a full buffer fetch.', {
919
+ format: 'pgs',
920
+ details: { reason: error instanceof Error ? error.message : String(error) }
921
+ }));
922
+ const { data } = await fetchSubtitleAsset(url, { preferRange: this.rangeRequests });
923
+ await this.loadPgsBuffer(data, false);
766
924
  }
767
925
  }
768
926
  async loadOnMainThread(data) {
769
- // Yield to browser before heavy parsing
770
927
  await this.yieldToMain();
771
928
  this.pgsParser = new PgsParser({ debug: this.debug, onWarning: (warning) => this.emitWarning(warning) });
772
- // Parse in a microtask to allow UI to update
773
929
  await new Promise((resolve) => {
774
- // Use requestIdleCallback if available, otherwise setTimeout
775
930
  const scheduleTask = typeof requestIdleCallback !== 'undefined'
776
931
  ? (cb) => requestIdleCallback(() => cb(), { timeout: 1000 })
777
932
  : (cb) => setTimeout(cb, 0);
@@ -781,6 +936,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
781
936
  this.state.metadata = this.pgsParser.getMetadata();
782
937
  this.isLoaded = true;
783
938
  this.setParserMetadata(this.state.metadata);
939
+ this.emitIndexed('pgs', this.state.metadata, false);
784
940
  if (count === 0) {
785
941
  this.state.renderIssues.set(-1, 'INVALID_SUBTITLE_DATA');
786
942
  }
@@ -962,9 +1118,9 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
962
1118
  pendingIndexLookup = null;
963
1119
  constructor(options) {
964
1120
  super(options, 'vobsub');
965
- this.idxUrl = options.idxUrl || (options.subUrl && /\.sub$/i.test(options.subUrl)
966
- ? options.subUrl.replace(/\.sub$/i, '.idx')
967
- : undefined);
1121
+ this.idxUrl =
1122
+ options.idxUrl ||
1123
+ (options.subUrl && /\.sub$/i.test(options.subUrl) ? options.subUrl.replace(/\.sub$/i, '.idx') : undefined);
968
1124
  this.idxContent = options.idxContent;
969
1125
  this.fileName = options.fileName;
970
1126
  this.onLoading = options.onLoading;
@@ -977,125 +1133,251 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
977
1133
  try {
978
1134
  this.emitEvent({ type: 'loading', format: 'vobsub' });
979
1135
  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;
1136
+ const useMksSource = !this.idxContent &&
1137
+ !this.idxUrl &&
1138
+ isMksSource({
1139
+ subData: this.subContent,
1140
+ fileName: this.fileName,
1141
+ subUrl: this.subUrl
1142
+ });
1143
+ if (this.subContent && (useMksSource || this.idxContent)) {
1144
+ const subData = new Uint8Array(this.subContent);
1145
+ this.emitLoadProgress('vobsub', this.memoryProgress(subData.byteLength), 0);
1146
+ await this.loadVobSubBuffer(subData, this.idxContent, useMksSource, true);
1147
+ this.onLoaded?.();
1148
+ return;
990
1149
  }
991
- // Resolve IDX content
992
- if (this.idxContent) {
993
- idxData = this.idxContent;
1150
+ if (useMksSource) {
1151
+ await this.loadVobSubMksStreaming();
1152
+ this.onLoaded?.();
1153
+ return;
1154
+ }
1155
+ await this.loadVobSubIdxSubStreaming();
1156
+ this.onLoaded?.();
1157
+ }
1158
+ catch (error) {
1159
+ const resolvedError = normalizeSubtitleError(error, { format: 'vobsub' });
1160
+ this.emitEvent({ type: 'error', format: 'vobsub', error: resolvedError });
1161
+ this.onError?.(resolvedError);
1162
+ }
1163
+ }
1164
+ applyVobSubIndexState(metadata, timestamps, partial, renderable, usingWorker) {
1165
+ this.state.metadata = metadata;
1166
+ this.state.timestamps = timestamps;
1167
+ this.setParserMetadata(metadata);
1168
+ this.emitIndexed('vobsub', metadata, partial);
1169
+ if (renderable && !this.isLoaded && timestamps.length > 0) {
1170
+ this.isLoaded = true;
1171
+ if (usingWorker) {
1172
+ this.state.workerReady = true;
1173
+ this.emitWorkerState(true, true, this.state.sessionId);
994
1174
  }
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' });
1175
+ }
1176
+ }
1177
+ async ensureVobSubWorkerSession() {
1178
+ if (!this.state.useWorker)
1179
+ return false;
1180
+ try {
1181
+ this.state.sessionId = createWorkerSessionId();
1182
+ await getOrCreateWorker();
1183
+ this.emitWorkerState(true, false, this.state.sessionId);
1184
+ return true;
1185
+ }
1186
+ catch (workerError) {
1187
+ this.state.useWorker = false;
1188
+ this.emitWorkerState(false, false, this.state.sessionId, true);
1189
+ this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'VobSub worker initialization failed, falling back to main-thread rendering.', {
1190
+ format: 'vobsub',
1191
+ details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
1192
+ }));
1193
+ return false;
1194
+ }
1195
+ }
1196
+ async loadVobSubBuffer(subData, idxData, useMksSource, preserveSource) {
1197
+ if (await this.ensureVobSubWorkerSession()) {
1198
+ try {
1199
+ const transferableSubData = createTransferableBuffer(subData, preserveSource);
1200
+ const loadResponse = await sendToWorker(useMksSource
1201
+ ? {
1202
+ type: 'loadVobSubMks',
1203
+ sessionId: this.state.sessionId,
1204
+ subData: transferableSubData
1205
+ }
1206
+ : {
1207
+ type: 'loadVobSub',
1208
+ sessionId: this.state.sessionId,
1209
+ idxContent: idxData,
1210
+ subData: transferableSubData
1211
+ });
1212
+ if (loadResponse.type === 'vobSubLoaded') {
1213
+ if (!useMksSource && idxData && loadResponse.count === 0) {
1214
+ throw createSubtitleDiagnosticError('BAD_IDX', 'IDX metadata did not yield any subtitle timestamps.', {
1215
+ format: 'vobsub'
1216
+ });
1217
+ }
1218
+ this.state.workerReady = true;
1219
+ this.state.metadata = loadResponse.metadata;
1220
+ this.state.timestamps = loadResponse.timestamps;
1221
+ this.isLoaded = true;
1222
+ this.setParserMetadata(loadResponse.metadata);
1223
+ this.emitIndexed('vobsub', loadResponse.metadata, false);
1224
+ this.emitWorkerState(true, true, this.state.sessionId);
1225
+ return;
1226
+ }
1227
+ else if (loadResponse.type === 'error') {
1228
+ throw new Error(loadResponse.message);
1000
1229
  }
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
1230
  }
1011
- if (!useMksSource && !idxData) {
1012
- if (!this.idxUrl) {
1013
- throw createSubtitleDiagnosticError('MISSING_INPUT', 'No IDX content or URL provided.', { format: 'vobsub' });
1231
+ catch (workerError) {
1232
+ if (workerError instanceof SubtitleDiagnosticError && workerError.code === 'BAD_IDX') {
1233
+ throw workerError;
1014
1234
  }
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;
1235
+ this.state.useWorker = false;
1236
+ this.emitWorkerState(false, false, this.state.sessionId, true);
1237
+ this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'VobSub worker load failed, falling back to main-thread rendering.', {
1238
+ format: 'vobsub',
1239
+ details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
1023
1240
  }));
1024
1241
  }
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' });
1242
+ }
1243
+ await this.loadOnMainThread(subData, idxData, useMksSource);
1244
+ }
1245
+ async loadVobSubMksStreaming() {
1246
+ if (this.subContent) {
1247
+ await this.loadVobSubBuffer(new Uint8Array(this.subContent), undefined, true, true);
1248
+ return;
1249
+ }
1250
+ if (!this.subUrl) {
1251
+ throw createSubtitleDiagnosticError('MISSING_INPUT', 'No SUB content or URL provided.', { format: 'vobsub' });
1252
+ }
1253
+ const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(this.subUrl, {
1254
+ preferRange: this.rangeRequests && this.streamingLoad,
1255
+ onProgress: (progress) => this.emitLoadProgress('vobsub', progress, this.state.timestamps.length)
1256
+ });
1257
+ this.emitLoadProgress('vobsub', {
1258
+ loaded: data.byteLength,
1259
+ total: total ?? data.byteLength,
1260
+ ratio: 1,
1261
+ rangeSupported,
1262
+ strategy
1263
+ }, 0);
1264
+ await this.loadVobSubBuffer(data, undefined, true, false);
1265
+ }
1266
+ async loadVobSubIdxSubStreaming() {
1267
+ let idxData = this.idxContent;
1268
+ if (!idxData) {
1269
+ if (!this.idxUrl) {
1270
+ throw createSubtitleDiagnosticError('MISSING_INPUT', 'No IDX content or URL provided.', { format: 'vobsub' });
1030
1271
  }
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
1272
+ idxData = await fetchSubtitleText(this.idxUrl, {
1273
+ onProgress: (progress) => this.emitLoadProgress('vobsub', progress, 0)
1274
+ });
1275
+ }
1276
+ let usedWorker = await this.ensureVobSubWorkerSession();
1277
+ if (usedWorker && this.state.sessionId) {
1278
+ try {
1279
+ const idxResponse = await sendToWorker({
1280
+ type: 'loadVobSubIdx',
1281
+ sessionId: this.state.sessionId,
1282
+ idxContent: idxData
1283
+ });
1284
+ if (idxResponse.type === 'vobSubProgress') {
1285
+ if (idxResponse.count === 0) {
1286
+ throw createSubtitleDiagnosticError('BAD_IDX', 'IDX metadata did not yield any subtitle timestamps.', {
1287
+ format: 'vobsub'
1049
1288
  });
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
1289
  }
1290
+ this.applyVobSubIndexState(idxResponse.metadata, idxResponse.timestamps, true, false, true);
1291
+ this.state.workerReady = true;
1292
+ this.emitWorkerState(true, true, this.state.sessionId);
1068
1293
  }
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
- }
1294
+ else if (idxResponse.type === 'error') {
1295
+ throw new Error(idxResponse.message);
1082
1296
  }
1083
1297
  }
1084
- // Main thread fallback
1085
- await this.loadOnMainThread(subData, idxData, useMksSource);
1086
- this.onLoaded?.();
1298
+ catch (error) {
1299
+ if (error instanceof SubtitleDiagnosticError && error.code === 'BAD_IDX') {
1300
+ throw error;
1301
+ }
1302
+ usedWorker = false;
1303
+ this.state.useWorker = false;
1304
+ this.emitWorkerState(false, false, this.state.sessionId, true);
1305
+ this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'VobSub worker IDX load failed, falling back to main-thread rendering.', {
1306
+ format: 'vobsub',
1307
+ details: { reason: error instanceof Error ? error.message : String(error) }
1308
+ }));
1309
+ }
1087
1310
  }
1088
- catch (error) {
1089
- const resolvedError = normalizeSubtitleError(error, { format: 'vobsub' });
1090
- this.emitEvent({ type: 'error', format: 'vobsub', error: resolvedError });
1091
- this.onError?.(resolvedError);
1311
+ if (!usedWorker) {
1312
+ await this.yieldToMain();
1313
+ this.vobsubParser = new VobSubParserLowLevel({
1314
+ debug: this.debug,
1315
+ onWarning: (warning) => this.emitWarning(warning)
1316
+ });
1317
+ this.vobsubParser.loadFromIdx(idxData);
1318
+ this.applyVobSubIndexState(this.vobsubParser.getMetadata(), this.vobsubParser.getTimestamps(), true, false, false);
1092
1319
  }
1320
+ let subData;
1321
+ if (this.subContent) {
1322
+ subData = new Uint8Array(this.subContent);
1323
+ this.emitLoadProgress('vobsub', this.memoryProgress(subData.byteLength), this.state.timestamps.length);
1324
+ }
1325
+ else {
1326
+ if (!this.subUrl) {
1327
+ throw createSubtitleDiagnosticError('MISSING_INPUT', 'No SUB content or URL provided.', { format: 'vobsub' });
1328
+ }
1329
+ const fetched = await fetchSubtitleAsset(this.subUrl, {
1330
+ preferRange: this.rangeRequests && this.streamingLoad,
1331
+ onProgress: (progress) => this.emitLoadProgress('vobsub', progress, this.state.timestamps.length)
1332
+ });
1333
+ subData = fetched.data;
1334
+ this.emitLoadProgress('vobsub', {
1335
+ loaded: subData.byteLength,
1336
+ total: fetched.total ?? subData.byteLength,
1337
+ ratio: 1,
1338
+ rangeSupported: fetched.rangeSupported,
1339
+ strategy: fetched.strategy
1340
+ }, this.state.timestamps.length);
1341
+ }
1342
+ if (usedWorker && this.state.sessionId) {
1343
+ const transferable = createTransferableBuffer(subData, Boolean(this.subContent));
1344
+ const attachResponse = await sendToWorker({
1345
+ type: 'attachVobSubData',
1346
+ sessionId: this.state.sessionId,
1347
+ subData: transferable
1348
+ });
1349
+ if (attachResponse.type === 'vobSubProgress') {
1350
+ this.applyVobSubIndexState(attachResponse.metadata, attachResponse.timestamps, false, true, true);
1351
+ this.state.workerReady = true;
1352
+ this.isLoaded = true;
1353
+ this.emitWorkerState(true, true, this.state.sessionId);
1354
+ return;
1355
+ }
1356
+ else if (attachResponse.type === 'error') {
1357
+ throw new Error(attachResponse.message);
1358
+ }
1359
+ }
1360
+ if (!this.vobsubParser) {
1361
+ this.vobsubParser = new VobSubParserLowLevel({
1362
+ debug: this.debug,
1363
+ onWarning: (warning) => this.emitWarning(warning)
1364
+ });
1365
+ this.vobsubParser.loadFromIdx(idxData);
1366
+ }
1367
+ this.vobsubParser.attachSubData(subData);
1368
+ const metadata = this.vobsubParser.getMetadata();
1369
+ this.state.timestamps = this.vobsubParser.getTimestamps();
1370
+ this.state.metadata = metadata;
1371
+ this.isLoaded = true;
1372
+ this.setParserMetadata(metadata);
1373
+ this.emitIndexed('vobsub', metadata, false);
1093
1374
  }
1094
1375
  async loadOnMainThread(subData, idxData, useMksSource = false) {
1095
- // Yield to browser before heavy parsing
1096
1376
  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
1377
+ this.vobsubParser = new VobSubParserLowLevel({
1378
+ debug: this.debug,
1379
+ onWarning: (warning) => this.emitWarning(warning)
1380
+ });
1099
1381
  await new Promise((resolve) => {
1100
1382
  const scheduleTask = typeof requestIdleCallback !== 'undefined'
1101
1383
  ? (cb) => requestIdleCallback(() => cb(), { timeout: 1000 })
@@ -1114,6 +1396,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
1114
1396
  this.state.metadata = this.vobsubParser.getMetadata();
1115
1397
  this.isLoaded = true;
1116
1398
  this.setParserMetadata(this.state.metadata);
1399
+ this.emitIndexed('vobsub', this.state.metadata, false);
1117
1400
  resolve();
1118
1401
  });
1119
1402
  });