libbitsub 1.10.2 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +296 -49
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/integrations/hlsjs.d.ts +45 -0
- package/dist/integrations/hlsjs.d.ts.map +1 -0
- package/dist/integrations/hlsjs.js +121 -0
- package/dist/integrations/hlsjs.js.map +1 -0
- package/dist/integrations/index.d.ts +11 -0
- package/dist/integrations/index.d.ts.map +1 -0
- package/dist/integrations/index.js +11 -0
- package/dist/integrations/index.js.map +1 -0
- package/dist/integrations/react.d.ts +55 -0
- package/dist/integrations/react.d.ts.map +1 -0
- package/dist/integrations/react.js +133 -0
- package/dist/integrations/react.js.map +1 -0
- package/dist/integrations/shaka.d.ts +38 -0
- package/dist/integrations/shaka.d.ts.map +1 -0
- package/dist/integrations/shaka.js +71 -0
- package/dist/integrations/shaka.js.map +1 -0
- package/dist/integrations/shared.d.ts +41 -0
- package/dist/integrations/shared.d.ts.map +1 -0
- package/dist/integrations/shared.js +123 -0
- package/dist/integrations/shared.js.map +1 -0
- package/dist/integrations/videojs.d.ts +85 -0
- package/dist/integrations/videojs.d.ts.map +1 -0
- package/dist/integrations/videojs.js +111 -0
- package/dist/integrations/videojs.js.map +1 -0
- package/dist/ts/capabilities.d.ts +8 -0
- package/dist/ts/capabilities.d.ts.map +1 -0
- package/dist/ts/capabilities.js +105 -0
- package/dist/ts/capabilities.js.map +1 -0
- package/dist/ts/diagnostics.d.ts.map +1 -1
- package/dist/ts/diagnostics.js.map +1 -1
- package/dist/ts/frame-export.d.ts.map +1 -1
- package/dist/ts/frame-export.js +4 -4
- package/dist/ts/frame-export.js.map +1 -1
- package/dist/ts/parsers.d.ts +46 -8
- package/dist/ts/parsers.d.ts.map +1 -1
- package/dist/ts/parsers.js +305 -14
- package/dist/ts/parsers.js.map +1 -1
- package/dist/ts/range-loader.d.ts +31 -0
- package/dist/ts/range-loader.d.ts.map +1 -0
- package/dist/ts/range-loader.js +220 -0
- package/dist/ts/range-loader.js.map +1 -0
- package/dist/ts/renderers.d.ts +140 -8
- package/dist/ts/renderers.d.ts.map +1 -1
- package/dist/ts/renderers.js +1773 -287
- package/dist/ts/renderers.js.map +1 -1
- package/dist/ts/types.d.ts +210 -7
- package/dist/ts/types.d.ts.map +1 -1
- package/dist/ts/utils.d.ts.map +1 -1
- package/dist/ts/utils.js +98 -2
- package/dist/ts/utils.js.map +1 -1
- package/dist/ts/video-frame-scheduler.d.ts +35 -0
- package/dist/ts/video-frame-scheduler.d.ts.map +1 -0
- package/dist/ts/video-frame-scheduler.js +98 -0
- package/dist/ts/video-frame-scheduler.js.map +1 -0
- package/dist/ts/wasm.d.ts +2 -0
- package/dist/ts/wasm.d.ts.map +1 -1
- package/dist/ts/wasm.js +12 -0
- package/dist/ts/wasm.js.map +1 -1
- package/dist/ts/webgl2-renderer.d.ts +9 -0
- package/dist/ts/webgl2-renderer.d.ts.map +1 -1
- package/dist/ts/webgl2-renderer.js +34 -0
- package/dist/ts/webgl2-renderer.js.map +1 -1
- package/dist/ts/webgpu-renderer.d.ts +9 -0
- package/dist/ts/webgpu-renderer.d.ts.map +1 -1
- package/dist/ts/webgpu-renderer.js +70 -2
- package/dist/ts/webgpu-renderer.js.map +1 -1
- package/dist/ts/worker.d.ts +11 -0
- package/dist/ts/worker.d.ts.map +1 -1
- package/dist/ts/worker.js +620 -158
- package/dist/ts/worker.js.map +1 -1
- package/dist/wrapper.d.ts +6 -3
- package/dist/wrapper.d.ts.map +1 -1
- package/dist/wrapper.js +5 -2
- package/dist/wrapper.js.map +1 -1
- package/examples/README.md +38 -0
- package/examples/hlsjs/README.md +53 -0
- package/examples/react/README.md +60 -0
- package/examples/shaka/README.md +61 -0
- package/examples/videojs/README.md +55 -0
- package/package.json +66 -8
- package/pkg/README.md +296 -49
- package/pkg/libbitsub.d.ts +65 -1
- package/pkg/libbitsub.js +228 -3
- package/pkg/libbitsub_bg.wasm +0 -0
- package/pkg/libbitsub_bg.wasm.d.ts +28 -0
- package/pkg/package.json +4 -4
- package/src/integrations/hlsjs.ts +154 -0
- package/src/integrations/index.ts +36 -0
- package/src/integrations/react.tsx +176 -0
- package/src/integrations/shaka.ts +92 -0
- package/src/integrations/shared.ts +171 -0
- package/src/integrations/videojs.ts +200 -0
- package/src/wrapper.ts +34 -2
package/dist/ts/renderers.js
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
* High-level video-integrated subtitle renderers for libbitsub.
|
|
3
3
|
* Handles canvas overlay, video sync, and subtitle fetching.
|
|
4
4
|
*/
|
|
5
|
-
import { createSubtitleDiagnosticError, createSubtitleWarning, formatSubtitleWarningForConsole, normalizeSubtitleError, warningFromRenderIssue } from './diagnostics';
|
|
5
|
+
import { SubtitleDiagnosticError, createSubtitleDiagnosticError, createSubtitleWarning, formatSubtitleWarningForConsole, normalizeSubtitleError, warningFromRenderIssue } from './diagnostics';
|
|
6
|
+
import { fetchSubtitleAsset, fetchSubtitleText } from './range-loader';
|
|
6
7
|
import { initWasm } from './wasm';
|
|
7
8
|
import { getOrCreateWorker, sendToWorker } from './worker';
|
|
9
|
+
import { canUseWorkerOffscreenRender } from './capabilities';
|
|
8
10
|
import { binarySearchTimestamp, convertFrameData, createWorkerSessionId, createWorkerState, detectSubtitleFormat, getSubtitleBounds, isMksSource, setCacheLimit as applyCacheLimit, setCachedFrame } from './utils';
|
|
9
|
-
import { PgsParser, VobSubParserLowLevel } from './parsers';
|
|
11
|
+
import { PgsParser, DvbParser, VobSubParserLowLevel } from './parsers';
|
|
10
12
|
import { WebGPURenderer, isWebGPUSupported } from './webgpu-renderer';
|
|
11
13
|
import { WebGL2Renderer, isWebGL2Supported } from './webgl2-renderer';
|
|
14
|
+
import { VideoFrameScheduler, supportsFrameAwareSync } from './video-frame-scheduler';
|
|
12
15
|
/** Default display settings */
|
|
13
16
|
const DEFAULT_DISPLAY_SETTINGS = {
|
|
14
17
|
scale: 1.0,
|
|
@@ -39,8 +42,14 @@ class BaseVideoSubtitleRenderer {
|
|
|
39
42
|
subUrl;
|
|
40
43
|
subContent;
|
|
41
44
|
canvas = null;
|
|
45
|
+
providedCanvas;
|
|
46
|
+
requestedContainer;
|
|
47
|
+
overlayContainer = null;
|
|
48
|
+
ownsCanvas = false;
|
|
49
|
+
devicePixelRatioCap;
|
|
50
|
+
backendOption;
|
|
42
51
|
ctx = null;
|
|
43
|
-
|
|
52
|
+
frameScheduler = null;
|
|
44
53
|
isLoaded = false;
|
|
45
54
|
lastRenderedIndex = -1;
|
|
46
55
|
lastRenderedTime = -1;
|
|
@@ -54,11 +63,12 @@ class BaseVideoSubtitleRenderer {
|
|
|
54
63
|
parserMetadata = null;
|
|
55
64
|
/** Display settings for subtitle rendering */
|
|
56
65
|
displaySettings = { ...DEFAULT_DISPLAY_SETTINGS };
|
|
57
|
-
|
|
58
|
-
timeOffset = 0;
|
|
66
|
+
_timeOffset = 0;
|
|
59
67
|
cacheLimit = 24;
|
|
60
68
|
prefetchBefore = 0;
|
|
61
69
|
prefetchAfter = 0;
|
|
70
|
+
streamingLoad = true;
|
|
71
|
+
rangeRequests = true;
|
|
62
72
|
onEvent;
|
|
63
73
|
onWarning;
|
|
64
74
|
currentRendererBackend = null;
|
|
@@ -66,6 +76,7 @@ class BaseVideoSubtitleRenderer {
|
|
|
66
76
|
lastRenderInfo = null;
|
|
67
77
|
loadedMetadataHandler = null;
|
|
68
78
|
seekedHandler = null;
|
|
79
|
+
initPromise = null;
|
|
69
80
|
// WebGPU renderer (optional, falls back to WebGL2 then Canvas2D)
|
|
70
81
|
webgpuRenderer = null;
|
|
71
82
|
useWebGPU = false;
|
|
@@ -74,6 +85,18 @@ class BaseVideoSubtitleRenderer {
|
|
|
74
85
|
webgl2Renderer = null;
|
|
75
86
|
useWebGL2 = false;
|
|
76
87
|
onWebGL2Fallback;
|
|
88
|
+
offscreenRenderOption;
|
|
89
|
+
frameAwareSync;
|
|
90
|
+
pendingWorkerOffscreen = false;
|
|
91
|
+
useWorkerOffscreen = false;
|
|
92
|
+
canvasPixelWidth = 1;
|
|
93
|
+
canvasPixelHeight = 1;
|
|
94
|
+
presentationToken = 0;
|
|
95
|
+
offscreenAttachPromise = null;
|
|
96
|
+
offscreenTransferPending = false;
|
|
97
|
+
offscreenFrameMetadata = new Map();
|
|
98
|
+
lastSynchronizedTime = null;
|
|
99
|
+
lastPresentedFrames = null;
|
|
77
100
|
// Performance tracking
|
|
78
101
|
perfStats = {
|
|
79
102
|
framesRendered: 0,
|
|
@@ -88,8 +111,19 @@ class BaseVideoSubtitleRenderer {
|
|
|
88
111
|
this.format = format;
|
|
89
112
|
this.subUrl = options.subUrl;
|
|
90
113
|
this.subContent = options.subContent;
|
|
114
|
+
this.providedCanvas = options.canvas ?? null;
|
|
115
|
+
this.requestedContainer = options.container ?? null;
|
|
116
|
+
this.devicePixelRatioCap =
|
|
117
|
+
options.devicePixelRatioCap !== undefined &&
|
|
118
|
+
Number.isFinite(options.devicePixelRatioCap) &&
|
|
119
|
+
options.devicePixelRatioCap > 0
|
|
120
|
+
? options.devicePixelRatioCap
|
|
121
|
+
: null;
|
|
122
|
+
this.backendOption = options.backend ?? 'auto';
|
|
91
123
|
this.onWebGPUFallback = options.onWebGPUFallback;
|
|
92
124
|
this.onWebGL2Fallback = options.onWebGL2Fallback;
|
|
125
|
+
this.offscreenRenderOption = options.offscreenRender !== false;
|
|
126
|
+
this.frameAwareSync = options.frameAwareSync !== false;
|
|
93
127
|
this.onEvent = options.onEvent;
|
|
94
128
|
this.onWarning = options.onWarning;
|
|
95
129
|
this.debug = Boolean(options.debug);
|
|
@@ -98,11 +132,51 @@ class BaseVideoSubtitleRenderer {
|
|
|
98
132
|
this.cacheLimit = Math.max(0, Math.floor(options.cacheLimit ?? 24));
|
|
99
133
|
this.prefetchBefore = Math.max(0, Math.floor(options.prefetchWindow?.before ?? 0));
|
|
100
134
|
this.prefetchAfter = Math.max(0, Math.floor(options.prefetchWindow?.after ?? 0));
|
|
135
|
+
this.streamingLoad = options.streamingLoad !== false;
|
|
136
|
+
this.rangeRequests = options.rangeRequests !== false;
|
|
137
|
+
}
|
|
138
|
+
emitLoadProgress(format, progress, indexedCues) {
|
|
139
|
+
this.emitEvent({
|
|
140
|
+
type: 'load-progress',
|
|
141
|
+
format,
|
|
142
|
+
loadedBytes: progress.loaded,
|
|
143
|
+
totalBytes: progress.total,
|
|
144
|
+
ratio: progress.ratio,
|
|
145
|
+
strategy: progress.strategy,
|
|
146
|
+
rangeSupported: progress.rangeSupported,
|
|
147
|
+
indexedCues
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
emitIndexed(format, metadata, partial) {
|
|
151
|
+
this.emitEvent({ type: 'indexed', format, metadata, partial });
|
|
152
|
+
}
|
|
153
|
+
memoryProgress(byteLength) {
|
|
154
|
+
return {
|
|
155
|
+
loaded: byteLength,
|
|
156
|
+
total: byteLength,
|
|
157
|
+
ratio: 1,
|
|
158
|
+
rangeSupported: false,
|
|
159
|
+
strategy: 'memory'
|
|
160
|
+
};
|
|
101
161
|
}
|
|
102
162
|
/** Get current display settings */
|
|
103
163
|
getDisplaySettings() {
|
|
104
164
|
return { ...this.displaySettings };
|
|
105
165
|
}
|
|
166
|
+
/** Time offset in seconds added to the active video clock for subtitle lookup. */
|
|
167
|
+
get timeOffset() {
|
|
168
|
+
return this._timeOffset;
|
|
169
|
+
}
|
|
170
|
+
set timeOffset(value) {
|
|
171
|
+
if (value === this._timeOffset)
|
|
172
|
+
return;
|
|
173
|
+
this._timeOffset = value;
|
|
174
|
+
this.invalidatePresentation();
|
|
175
|
+
this.lastSynchronizedTime = null;
|
|
176
|
+
this.lastRenderedIndex = -1;
|
|
177
|
+
this.lastRenderedTime = -1;
|
|
178
|
+
this.renderPausedFrame();
|
|
179
|
+
}
|
|
106
180
|
/** Get cache-related diagnostics for the active renderer session. */
|
|
107
181
|
getCacheStats() {
|
|
108
182
|
const state = this.getWorkerRendererState();
|
|
@@ -159,9 +233,16 @@ class BaseVideoSubtitleRenderer {
|
|
|
159
233
|
minRenderTime: Math.round(minRenderTime * 100) / 100,
|
|
160
234
|
lastRenderTime: Math.round(this.perfStats.lastRenderTime * 100) / 100,
|
|
161
235
|
renderFps: this.perfStats.fpsTimestamps.length,
|
|
162
|
-
currentIndex: this.lastRenderedIndex
|
|
236
|
+
currentIndex: this.lastRenderedIndex,
|
|
237
|
+
syncMode: this.getSynchronizationMode()
|
|
163
238
|
};
|
|
164
239
|
}
|
|
240
|
+
/** Get the active video synchronization clock. */
|
|
241
|
+
getSynchronizationMode() {
|
|
242
|
+
if (this.frameScheduler)
|
|
243
|
+
return this.frameScheduler.mode;
|
|
244
|
+
return supportsFrameAwareSync(this.video, this.frameAwareSync) ? 'video-frame' : 'animation-frame';
|
|
245
|
+
}
|
|
165
246
|
/** Set display settings and force re-render */
|
|
166
247
|
setDisplaySettings(settings) {
|
|
167
248
|
const nextSettings = {
|
|
@@ -181,67 +262,300 @@ class BaseVideoSubtitleRenderer {
|
|
|
181
262
|
this.displaySettings = nextSettings;
|
|
182
263
|
// Force re-render if settings changed
|
|
183
264
|
if (changed) {
|
|
265
|
+
this.invalidatePresentation();
|
|
184
266
|
this.lastRenderedIndex = -1;
|
|
185
267
|
this.lastRenderedTime = -1;
|
|
268
|
+
this.renderPausedFrame();
|
|
186
269
|
}
|
|
187
270
|
}
|
|
188
271
|
/** Reset display settings to defaults */
|
|
189
272
|
resetDisplaySettings() {
|
|
190
273
|
this.displaySettings = { ...DEFAULT_DISPLAY_SETTINGS };
|
|
274
|
+
this.invalidatePresentation();
|
|
191
275
|
this.lastRenderedIndex = -1;
|
|
192
276
|
this.lastRenderedTime = -1;
|
|
277
|
+
this.renderPausedFrame();
|
|
193
278
|
}
|
|
194
279
|
/** Start initialization. */
|
|
195
280
|
startInit() {
|
|
196
|
-
this.init()
|
|
197
|
-
|
|
281
|
+
this.initPromise = this.init();
|
|
282
|
+
this.initPromise.catch((error) => {
|
|
283
|
+
this.emitEvent({
|
|
284
|
+
type: 'error',
|
|
285
|
+
format: this.format,
|
|
286
|
+
error: normalizeSubtitleError(error, { format: this.format })
|
|
287
|
+
});
|
|
198
288
|
});
|
|
199
289
|
}
|
|
290
|
+
/** Wait for canvas, parser, and worker initialization before accepting pushed data. */
|
|
291
|
+
async waitUntilInitialized() {
|
|
292
|
+
await this.initPromise;
|
|
293
|
+
if (this.disposed) {
|
|
294
|
+
throw new Error(`${this.format.toUpperCase()} renderer has been disposed`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** Re-run cue selection after a live stream mutation. */
|
|
298
|
+
refreshStreamPresentation() {
|
|
299
|
+
this.invalidatePresentation();
|
|
300
|
+
this.lastRenderedIndex = -1;
|
|
301
|
+
this.lastRenderedTime = -1;
|
|
302
|
+
this.renderPausedFrame();
|
|
303
|
+
}
|
|
304
|
+
/** Clear the currently presented cue after a live stream reset. */
|
|
305
|
+
clearStreamPresentation() {
|
|
306
|
+
this.invalidatePresentation();
|
|
307
|
+
this.lastSynchronizedTime = null;
|
|
308
|
+
this.lastRenderedIndex = -1;
|
|
309
|
+
this.lastRenderedTime = -1;
|
|
310
|
+
this.lastRenderedData = null;
|
|
311
|
+
this.lastCueIndex = null;
|
|
312
|
+
this.currentCueMetadata = null;
|
|
313
|
+
this.lastRenderInfo = null;
|
|
314
|
+
this.offscreenFrameMetadata.clear();
|
|
315
|
+
const state = this.getWorkerRendererState();
|
|
316
|
+
if (this.useWorkerOffscreen && state.workerReady && state.sessionId) {
|
|
317
|
+
sendToWorker({ type: 'clearOffscreenCanvas', sessionId: state.sessionId }).catch(() => { });
|
|
318
|
+
}
|
|
319
|
+
else if (this.useWebGPU && this.webgpuRenderer) {
|
|
320
|
+
this.webgpuRenderer.clear();
|
|
321
|
+
}
|
|
322
|
+
else if (this.useWebGL2 && this.webgl2Renderer) {
|
|
323
|
+
this.webgl2Renderer.clear();
|
|
324
|
+
}
|
|
325
|
+
else if (this.ctx && this.canvas) {
|
|
326
|
+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
327
|
+
}
|
|
328
|
+
this.emitEvent({ type: 'cue-change', cue: null });
|
|
329
|
+
}
|
|
200
330
|
/** Initialize the renderer. */
|
|
201
331
|
async init() {
|
|
202
332
|
await initWasm();
|
|
203
333
|
this.createCanvas();
|
|
204
334
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
205
335
|
await this.loadSubtitles();
|
|
336
|
+
await this.ensureWorkerOffscreenAttached();
|
|
206
337
|
this.startRenderLoop();
|
|
207
338
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
339
|
+
shouldPreferWorkerOffscreen() {
|
|
340
|
+
return !this.providedCanvas && this.offscreenRenderOption && canUseWorkerOffscreenRender();
|
|
341
|
+
}
|
|
342
|
+
isWorkerOffscreenPresent() {
|
|
343
|
+
return this.useWorkerOffscreen;
|
|
344
|
+
}
|
|
345
|
+
getDefaultContainer() {
|
|
346
|
+
if (this.video.parentElement)
|
|
347
|
+
return this.video.parentElement;
|
|
348
|
+
const parent = this.video.parentNode;
|
|
349
|
+
if (parent && 'appendChild' in parent && 'host' in parent) {
|
|
350
|
+
return parent;
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
getContainerElement(container) {
|
|
355
|
+
return 'host' in container ? container.host : container;
|
|
356
|
+
}
|
|
357
|
+
getCanvasParent(canvas) {
|
|
358
|
+
return canvas.parentNode ?? canvas.parentElement;
|
|
359
|
+
}
|
|
360
|
+
removeOwnedCanvas() {
|
|
361
|
+
if (!this.ownsCanvas || !this.canvas)
|
|
362
|
+
return;
|
|
363
|
+
this.getCanvasParent(this.canvas)?.removeChild(this.canvas);
|
|
364
|
+
}
|
|
365
|
+
mountOverlayCanvas(canvas) {
|
|
366
|
+
if (!canvas.style.position)
|
|
367
|
+
canvas.style.position = 'absolute';
|
|
368
|
+
if (!canvas.style.pointerEvents)
|
|
369
|
+
canvas.style.pointerEvents = 'none';
|
|
370
|
+
if (!canvas.style.zIndex)
|
|
371
|
+
canvas.style.zIndex = '10';
|
|
372
|
+
const existingContainer = this.getCanvasParent(canvas);
|
|
373
|
+
const container = this.requestedContainer ??
|
|
374
|
+
(existingContainer &&
|
|
375
|
+
'appendChild' in existingContainer &&
|
|
376
|
+
('style' in existingContainer || 'host' in existingContainer)
|
|
377
|
+
? existingContainer
|
|
378
|
+
: this.getDefaultContainer());
|
|
379
|
+
this.overlayContainer = container;
|
|
380
|
+
if (!container)
|
|
381
|
+
return;
|
|
382
|
+
const containerElement = this.getContainerElement(container);
|
|
383
|
+
if (window.getComputedStyle(containerElement).position === 'static') {
|
|
384
|
+
containerElement.style.position = 'relative';
|
|
385
|
+
}
|
|
386
|
+
if (this.getCanvasParent(canvas) !== container) {
|
|
387
|
+
container.appendChild(canvas);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
selectBackend() {
|
|
391
|
+
if (this.backendOption === 'webgpu') {
|
|
392
|
+
void this.initWebGPU(false);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (this.backendOption === 'webgl2') {
|
|
396
|
+
void this.initWebGL2(false);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (this.backendOption === 'worker-offscreen') {
|
|
400
|
+
this.pendingWorkerOffscreen = true;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (this.backendOption === 'canvas2d') {
|
|
404
|
+
this.initCanvas2D();
|
|
405
|
+
return;
|
|
222
406
|
}
|
|
223
|
-
// Try WebGPU first, then WebGL2, then Canvas2D
|
|
224
407
|
if (isWebGPUSupported()) {
|
|
225
|
-
this.initWebGPU();
|
|
408
|
+
void this.initWebGPU();
|
|
226
409
|
}
|
|
227
410
|
else if (isWebGL2Supported()) {
|
|
228
|
-
this.initWebGL2();
|
|
411
|
+
void this.initWebGL2();
|
|
412
|
+
}
|
|
413
|
+
else if (this.shouldPreferWorkerOffscreen()) {
|
|
414
|
+
this.pendingWorkerOffscreen = true;
|
|
229
415
|
}
|
|
230
416
|
else {
|
|
231
417
|
this.initCanvas2D();
|
|
232
418
|
}
|
|
419
|
+
}
|
|
420
|
+
/** Create the canvas overlay positioned over the video. */
|
|
421
|
+
createCanvas() {
|
|
422
|
+
this.canvas = this.providedCanvas ?? document.createElement('canvas');
|
|
423
|
+
this.ownsCanvas = !this.providedCanvas;
|
|
424
|
+
this.mountOverlayCanvas(this.canvas);
|
|
425
|
+
this.selectBackend();
|
|
233
426
|
this.updateCanvasSize();
|
|
234
427
|
this.resizeObserver = new ResizeObserver(() => this.updateCanvasSize());
|
|
235
428
|
this.resizeObserver.observe(this.video);
|
|
236
429
|
this.loadedMetadataHandler = () => this.updateCanvasSize();
|
|
237
430
|
this.seekedHandler = () => {
|
|
431
|
+
this.invalidatePresentation();
|
|
238
432
|
this.lastRenderedIndex = -1;
|
|
239
433
|
this.lastRenderedTime = -1;
|
|
434
|
+
this.lastSynchronizedTime = null;
|
|
435
|
+
this.offscreenFrameMetadata.clear();
|
|
240
436
|
this.onSeek();
|
|
437
|
+
this.renderPausedFrame();
|
|
241
438
|
};
|
|
242
439
|
this.video.addEventListener('loadedmetadata', this.loadedMetadataHandler);
|
|
243
440
|
this.video.addEventListener('seeked', this.seekedHandler);
|
|
244
441
|
}
|
|
442
|
+
preferWorkerOffscreenOrCanvas2D() {
|
|
443
|
+
if (this.backendOption === 'auto' && this.shouldPreferWorkerOffscreen()) {
|
|
444
|
+
this.pendingWorkerOffscreen = true;
|
|
445
|
+
const state = this.getWorkerRendererState();
|
|
446
|
+
if (state.useWorker && state.workerReady && state.sessionId) {
|
|
447
|
+
void this.ensureWorkerOffscreenAttached();
|
|
448
|
+
}
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
this.initCanvas2D();
|
|
452
|
+
}
|
|
453
|
+
ensureWorkerOffscreenAttached() {
|
|
454
|
+
if (this.disposed)
|
|
455
|
+
return Promise.resolve();
|
|
456
|
+
if (this.offscreenAttachPromise)
|
|
457
|
+
return this.offscreenAttachPromise;
|
|
458
|
+
if (!this.pendingWorkerOffscreen || !this.canvas)
|
|
459
|
+
return Promise.resolve();
|
|
460
|
+
const state = this.getWorkerRendererState();
|
|
461
|
+
if (!state.useWorker || !state.workerReady || !state.sessionId) {
|
|
462
|
+
this.pendingWorkerOffscreen = false;
|
|
463
|
+
this.initCanvas2D();
|
|
464
|
+
this.updateCanvasSize();
|
|
465
|
+
return Promise.resolve();
|
|
466
|
+
}
|
|
467
|
+
const sessionId = state.sessionId;
|
|
468
|
+
this.pendingWorkerOffscreen = false;
|
|
469
|
+
this.offscreenTransferPending = true;
|
|
470
|
+
let attachPromise;
|
|
471
|
+
attachPromise = this.attachWorkerOffscreen(sessionId).finally(() => {
|
|
472
|
+
this.offscreenTransferPending = false;
|
|
473
|
+
if (this.offscreenAttachPromise === attachPromise) {
|
|
474
|
+
this.offscreenAttachPromise = null;
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
this.offscreenAttachPromise = attachPromise;
|
|
478
|
+
return attachPromise;
|
|
479
|
+
}
|
|
480
|
+
async attachWorkerOffscreen(sessionId) {
|
|
481
|
+
try {
|
|
482
|
+
const transferableCanvas = this.canvas;
|
|
483
|
+
const offscreen = transferableCanvas.transferControlToOffscreen();
|
|
484
|
+
const attachResponse = await sendToWorker({
|
|
485
|
+
type: 'attachOffscreenCanvas',
|
|
486
|
+
sessionId,
|
|
487
|
+
canvas: offscreen
|
|
488
|
+
});
|
|
489
|
+
if (attachResponse.type === 'error') {
|
|
490
|
+
throw new Error(attachResponse.message);
|
|
491
|
+
}
|
|
492
|
+
if (attachResponse.type !== 'offscreenAttached') {
|
|
493
|
+
throw new Error('Worker OffscreenCanvas attach failed');
|
|
494
|
+
}
|
|
495
|
+
if (this.disposed) {
|
|
496
|
+
await sendToWorker({ type: 'detachOffscreenCanvas', sessionId });
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
this.useWorkerOffscreen = true;
|
|
500
|
+
this.emitRendererBackend('worker-offscreen');
|
|
501
|
+
this.lastRenderedIndex = -1;
|
|
502
|
+
this.lastRenderedTime = -1;
|
|
503
|
+
await sendToWorker({
|
|
504
|
+
type: 'resizeOffscreenCanvas',
|
|
505
|
+
sessionId,
|
|
506
|
+
width: this.canvasPixelWidth,
|
|
507
|
+
height: this.canvasPixelHeight
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
this.offscreenTransferPending = false;
|
|
512
|
+
this.useWorkerOffscreen = false;
|
|
513
|
+
if (this.disposed)
|
|
514
|
+
return;
|
|
515
|
+
this.recreateCanvasForMainThreadFallback();
|
|
516
|
+
this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'Worker OffscreenCanvas present unavailable; falling back to main-thread Canvas2D.', {
|
|
517
|
+
format: this.format,
|
|
518
|
+
details: { reason: error instanceof Error ? error.message : String(error) }
|
|
519
|
+
}));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
recreateCanvasForMainThreadFallback() {
|
|
523
|
+
this.removeOwnedCanvas();
|
|
524
|
+
this.canvas = document.createElement('canvas');
|
|
525
|
+
this.ownsCanvas = true;
|
|
526
|
+
this.mountOverlayCanvas(this.canvas);
|
|
527
|
+
this.initCanvas2D();
|
|
528
|
+
this.updateCanvasSize();
|
|
529
|
+
if (!this.tempCanvas) {
|
|
530
|
+
this.tempCanvas = document.createElement('canvas');
|
|
531
|
+
this.tempCtx = this.tempCanvas.getContext('2d');
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
fallbackFromWorkerOffscreen(reason) {
|
|
535
|
+
if (!this.useWorkerOffscreen || this.disposed)
|
|
536
|
+
return;
|
|
537
|
+
const state = this.getWorkerRendererState();
|
|
538
|
+
this.invalidatePresentation();
|
|
539
|
+
this.useWorkerOffscreen = false;
|
|
540
|
+
this.pendingWorkerOffscreen = false;
|
|
541
|
+
this.offscreenFrameMetadata.clear();
|
|
542
|
+
if (state.sessionId) {
|
|
543
|
+
sendToWorker({ type: 'detachOffscreenCanvas', sessionId: state.sessionId }).catch(() => { });
|
|
544
|
+
}
|
|
545
|
+
this.recreateCanvasForMainThreadFallback();
|
|
546
|
+
this.lastRenderedIndex = -2;
|
|
547
|
+
this.lastRenderedTime = -1;
|
|
548
|
+
this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'Worker OffscreenCanvas present failed; falling back to main-thread Canvas2D.', {
|
|
549
|
+
format: this.format,
|
|
550
|
+
details: { reason }
|
|
551
|
+
}));
|
|
552
|
+
}
|
|
553
|
+
getOffscreenFrameMetadata(index) {
|
|
554
|
+
return this.offscreenFrameMetadata.get(index) ?? null;
|
|
555
|
+
}
|
|
556
|
+
clearOffscreenFrameMetadata() {
|
|
557
|
+
this.offscreenFrameMetadata.clear();
|
|
558
|
+
}
|
|
245
559
|
emitEvent(event) {
|
|
246
560
|
this.onEvent?.(event);
|
|
247
561
|
}
|
|
@@ -285,15 +599,16 @@ class BaseVideoSubtitleRenderer {
|
|
|
285
599
|
this.lastRenderInfo = info;
|
|
286
600
|
}
|
|
287
601
|
/** Initialize WebGPU renderer. */
|
|
288
|
-
async initWebGPU() {
|
|
602
|
+
async initWebGPU(allowBackendFallback = true) {
|
|
289
603
|
try {
|
|
290
604
|
this.webgpuRenderer = new WebGPURenderer();
|
|
291
605
|
await this.webgpuRenderer.init();
|
|
292
606
|
if (!this.canvas)
|
|
293
607
|
return;
|
|
294
608
|
const bounds = this.getVideoContentBounds();
|
|
295
|
-
const
|
|
296
|
-
const
|
|
609
|
+
const pixelRatio = this.getDevicePixelRatio();
|
|
610
|
+
const width = Math.max(1, bounds.width * pixelRatio);
|
|
611
|
+
const height = Math.max(1, bounds.height * pixelRatio);
|
|
297
612
|
await this.webgpuRenderer.setCanvas(this.canvas, width, height);
|
|
298
613
|
this.useWebGPU = true;
|
|
299
614
|
this.emitRendererBackend('webgpu');
|
|
@@ -303,25 +618,25 @@ class BaseVideoSubtitleRenderer {
|
|
|
303
618
|
this.webgpuRenderer = null;
|
|
304
619
|
this.useWebGPU = false;
|
|
305
620
|
this.onWebGPUFallback?.();
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
this.initWebGL2();
|
|
621
|
+
if (allowBackendFallback && isWebGL2Supported()) {
|
|
622
|
+
void this.initWebGL2();
|
|
309
623
|
}
|
|
310
624
|
else {
|
|
311
|
-
this.
|
|
625
|
+
this.preferWorkerOffscreenOrCanvas2D();
|
|
312
626
|
}
|
|
313
627
|
}
|
|
314
628
|
}
|
|
315
629
|
/** Initialize WebGL2 renderer. */
|
|
316
|
-
async initWebGL2() {
|
|
630
|
+
async initWebGL2(allowBackendFallback = true) {
|
|
317
631
|
try {
|
|
318
632
|
this.webgl2Renderer = new WebGL2Renderer();
|
|
319
633
|
await this.webgl2Renderer.init();
|
|
320
634
|
if (!this.canvas)
|
|
321
635
|
return;
|
|
322
636
|
const bounds = this.getVideoContentBounds();
|
|
323
|
-
const
|
|
324
|
-
const
|
|
637
|
+
const pixelRatio = this.getDevicePixelRatio();
|
|
638
|
+
const width = Math.max(1, bounds.width * pixelRatio);
|
|
639
|
+
const height = Math.max(1, bounds.height * pixelRatio);
|
|
325
640
|
await this.webgl2Renderer.setCanvas(this.canvas, width, height);
|
|
326
641
|
this.useWebGL2 = true;
|
|
327
642
|
this.emitRendererBackend('webgl2');
|
|
@@ -331,7 +646,12 @@ class BaseVideoSubtitleRenderer {
|
|
|
331
646
|
this.webgl2Renderer = null;
|
|
332
647
|
this.useWebGL2 = false;
|
|
333
648
|
this.onWebGL2Fallback?.();
|
|
334
|
-
|
|
649
|
+
if (allowBackendFallback) {
|
|
650
|
+
this.preferWorkerOffscreenOrCanvas2D();
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
this.initCanvas2D();
|
|
654
|
+
}
|
|
335
655
|
}
|
|
336
656
|
}
|
|
337
657
|
/** Initialize Canvas2D renderer. */
|
|
@@ -345,6 +665,23 @@ class BaseVideoSubtitleRenderer {
|
|
|
345
665
|
}
|
|
346
666
|
/** Called when video seeks. */
|
|
347
667
|
onSeek() { }
|
|
668
|
+
getDevicePixelRatio() {
|
|
669
|
+
const devicePixelRatio = Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1;
|
|
670
|
+
return this.devicePixelRatioCap === null ? devicePixelRatio : Math.min(devicePixelRatio, this.devicePixelRatioCap);
|
|
671
|
+
}
|
|
672
|
+
getCanvasMountOffset() {
|
|
673
|
+
if (!this.overlayContainer)
|
|
674
|
+
return { x: 0, y: 0 };
|
|
675
|
+
const containerElement = this.getContainerElement(this.overlayContainer);
|
|
676
|
+
if (typeof containerElement.getBoundingClientRect !== 'function')
|
|
677
|
+
return { x: 0, y: 0 };
|
|
678
|
+
const videoRect = this.video.getBoundingClientRect();
|
|
679
|
+
const containerRect = containerElement.getBoundingClientRect();
|
|
680
|
+
return {
|
|
681
|
+
x: (videoRect.left ?? videoRect.x) - (containerRect.left ?? containerRect.x),
|
|
682
|
+
y: (videoRect.top ?? videoRect.y) - (containerRect.top ?? containerRect.y)
|
|
683
|
+
};
|
|
684
|
+
}
|
|
348
685
|
/** Calculate the actual video content bounds, accounting for letterboxing/pillarboxing */
|
|
349
686
|
getVideoContentBounds() {
|
|
350
687
|
const rect = this.video.getBoundingClientRect();
|
|
@@ -387,95 +724,181 @@ class BaseVideoSubtitleRenderer {
|
|
|
387
724
|
const bounds = this.getVideoContentBounds();
|
|
388
725
|
const width = bounds.width > 0 ? bounds.width : this.video.videoWidth || 1920;
|
|
389
726
|
const height = bounds.height > 0 ? bounds.height : this.video.videoHeight || 1080;
|
|
390
|
-
const
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
this.
|
|
727
|
+
const pixelRatio = this.getDevicePixelRatio();
|
|
728
|
+
const pixelWidth = Math.max(1, width * pixelRatio);
|
|
729
|
+
const pixelHeight = Math.max(1, height * pixelRatio);
|
|
730
|
+
this.canvasPixelWidth = pixelWidth;
|
|
731
|
+
this.canvasPixelHeight = pixelHeight;
|
|
394
732
|
// Position canvas to match video content area
|
|
395
|
-
|
|
396
|
-
this.canvas.style.
|
|
733
|
+
const mountOffset = this.getCanvasMountOffset();
|
|
734
|
+
this.canvas.style.left = `${mountOffset.x + bounds.x}px`;
|
|
735
|
+
this.canvas.style.top = `${mountOffset.y + bounds.y}px`;
|
|
397
736
|
this.canvas.style.width = `${bounds.width}px`;
|
|
398
737
|
this.canvas.style.height = `${bounds.height}px`;
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
this.
|
|
738
|
+
if (this.useWorkerOffscreen || this.pendingWorkerOffscreen || this.offscreenTransferPending) {
|
|
739
|
+
const state = this.getWorkerRendererState();
|
|
740
|
+
if (this.useWorkerOffscreen && state.sessionId && state.workerReady) {
|
|
741
|
+
sendToWorker({
|
|
742
|
+
type: 'resizeOffscreenCanvas',
|
|
743
|
+
sessionId: state.sessionId,
|
|
744
|
+
width: pixelWidth,
|
|
745
|
+
height: pixelHeight
|
|
746
|
+
}).catch(() => { });
|
|
747
|
+
}
|
|
402
748
|
}
|
|
403
|
-
else
|
|
404
|
-
this.
|
|
749
|
+
else {
|
|
750
|
+
this.canvas.width = pixelWidth;
|
|
751
|
+
this.canvas.height = pixelHeight;
|
|
752
|
+
// Update GPU renderer size if active
|
|
753
|
+
if (this.useWebGPU && this.webgpuRenderer) {
|
|
754
|
+
this.webgpuRenderer.updateSize(pixelWidth, pixelHeight);
|
|
755
|
+
}
|
|
756
|
+
else if (this.useWebGL2 && this.webgl2Renderer) {
|
|
757
|
+
this.webgl2Renderer.updateSize(pixelWidth, pixelHeight);
|
|
758
|
+
}
|
|
405
759
|
}
|
|
406
760
|
this.lastRenderedIndex = -1;
|
|
407
761
|
this.lastRenderedTime = -1;
|
|
762
|
+
this.invalidatePresentation();
|
|
763
|
+
if (this.frameScheduler) {
|
|
764
|
+
this.renderPausedFrame();
|
|
765
|
+
}
|
|
408
766
|
}
|
|
409
767
|
/** Start the render loop. */
|
|
410
768
|
startRenderLoop() {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
this.perfStats.renderTimes.shift();
|
|
433
|
-
}
|
|
434
|
-
this.perfStats.framesRendered++;
|
|
435
|
-
this.perfStats.fpsTimestamps.push(endTime);
|
|
436
|
-
// Check for frame drop (if render took longer than frame budget ~16.67ms for 60fps)
|
|
437
|
-
const frameBudget = 16.67;
|
|
438
|
-
if (renderTime > frameBudget) {
|
|
439
|
-
this.perfStats.framesDropped++;
|
|
440
|
-
}
|
|
441
|
-
if (outcome.warning) {
|
|
442
|
-
this.emitWarning(outcome.warning);
|
|
443
|
-
}
|
|
444
|
-
this.lastRenderedIndex = currentIndex;
|
|
445
|
-
this.lastRenderedTime = currentTime;
|
|
446
|
-
const cue = currentIndex >= 0 ? this.buildCueMetadata(currentIndex) : null;
|
|
447
|
-
this.emitCueChange(cue);
|
|
448
|
-
this.recordLastRenderInfo({
|
|
449
|
-
time: currentTime,
|
|
450
|
-
index: currentIndex,
|
|
451
|
-
status: outcome.status,
|
|
452
|
-
backend: this.currentRendererBackend,
|
|
453
|
-
usingWorker: this.getCacheStats().usingWorker,
|
|
454
|
-
cacheHit,
|
|
455
|
-
renderDuration: Math.round(renderTime * 100) / 100,
|
|
456
|
-
frameWidth: outcome.data?.width ?? null,
|
|
457
|
-
frameHeight: outcome.data?.height ?? null,
|
|
458
|
-
compositionCount: outcome.data?.compositionData.length ?? 0,
|
|
459
|
-
cue,
|
|
460
|
-
cache: this.getCacheStats(),
|
|
461
|
-
capturedAt: endTime
|
|
462
|
-
});
|
|
463
|
-
this.emitEvent({ type: 'stats', stats: this.getStats() });
|
|
464
|
-
if (currentIndex >= 0 && (this.prefetchBefore > 0 || this.prefetchAfter > 0)) {
|
|
465
|
-
const prefetch = this.prefetchAroundTime;
|
|
466
|
-
prefetch?.call(this, currentTime).catch(() => { });
|
|
467
|
-
}
|
|
468
|
-
}
|
|
769
|
+
if (!this.useWorkerOffscreen) {
|
|
770
|
+
this.tempCanvas = document.createElement('canvas');
|
|
771
|
+
this.tempCtx = this.tempCanvas.getContext('2d');
|
|
772
|
+
}
|
|
773
|
+
this.frameScheduler = new VideoFrameScheduler(this.video, this.frameAwareSync, (tick) => this.renderSynchronizedFrame(tick));
|
|
774
|
+
this.renderPausedFrame();
|
|
775
|
+
this.frameScheduler.start();
|
|
776
|
+
}
|
|
777
|
+
renderPausedFrame() {
|
|
778
|
+
if (!this.video.paused && !this.video.ended)
|
|
779
|
+
return;
|
|
780
|
+
this.renderSynchronizedFrame({ mediaTime: this.video.currentTime, presentedFrames: null });
|
|
781
|
+
}
|
|
782
|
+
renderSynchronizedFrame(tick) {
|
|
783
|
+
if (this.disposed || !this.isLoaded)
|
|
784
|
+
return;
|
|
785
|
+
const currentTime = tick.mediaTime + this.timeOffset;
|
|
786
|
+
this.lastSynchronizedTime = currentTime;
|
|
787
|
+
if (tick.presentedFrames !== null) {
|
|
788
|
+
if (this.lastPresentedFrames !== null && tick.presentedFrames > this.lastPresentedFrames + 1) {
|
|
789
|
+
this.perfStats.framesDropped += tick.presentedFrames - this.lastPresentedFrames - 1;
|
|
469
790
|
}
|
|
470
|
-
this.
|
|
471
|
-
}
|
|
472
|
-
|
|
791
|
+
this.lastPresentedFrames = tick.presentedFrames;
|
|
792
|
+
}
|
|
793
|
+
const currentIndex = this.findCurrentIndex(currentTime);
|
|
794
|
+
if (currentIndex === this.lastRenderedIndex)
|
|
795
|
+
return;
|
|
796
|
+
const cacheHit = !this.useWorkerOffscreen && currentIndex >= 0 && this.getWorkerRendererState().frameCache.has(currentIndex);
|
|
797
|
+
const workerOffscreenPresent = this.useWorkerOffscreen;
|
|
798
|
+
const startTime = performance.now();
|
|
799
|
+
this.presentationToken++;
|
|
800
|
+
const outcome = this.renderFrame(currentTime, currentIndex);
|
|
801
|
+
const endTime = performance.now();
|
|
802
|
+
this.lastRenderedIndex = currentIndex;
|
|
803
|
+
this.lastRenderedTime = currentTime;
|
|
804
|
+
// Worker OffscreenCanvas presentation completes asynchronously. Its
|
|
805
|
+
// callback records the real status, metadata, and render duration.
|
|
806
|
+
if (workerOffscreenPresent)
|
|
807
|
+
return;
|
|
808
|
+
const renderTime = endTime - startTime;
|
|
809
|
+
this.recordRenderPerformance(renderTime, endTime);
|
|
810
|
+
if (outcome.warning) {
|
|
811
|
+
this.emitWarning(outcome.warning);
|
|
812
|
+
}
|
|
813
|
+
const cue = currentIndex >= 0 ? this.buildCueMetadata(currentIndex) : null;
|
|
814
|
+
this.emitCueChange(cue);
|
|
815
|
+
this.recordLastRenderInfo({
|
|
816
|
+
time: currentTime,
|
|
817
|
+
index: currentIndex,
|
|
818
|
+
status: outcome.status,
|
|
819
|
+
backend: this.currentRendererBackend,
|
|
820
|
+
usingWorker: this.getCacheStats().usingWorker,
|
|
821
|
+
cacheHit,
|
|
822
|
+
renderDuration: Math.round(renderTime * 100) / 100,
|
|
823
|
+
frameWidth: outcome.data?.width ?? null,
|
|
824
|
+
frameHeight: outcome.data?.height ?? null,
|
|
825
|
+
compositionCount: outcome.data?.compositionData.length ?? 0,
|
|
826
|
+
cue,
|
|
827
|
+
cache: this.getCacheStats(),
|
|
828
|
+
capturedAt: endTime
|
|
829
|
+
});
|
|
830
|
+
this.emitEvent({ type: 'stats', stats: this.getStats() });
|
|
831
|
+
if (currentIndex >= 0 && (this.prefetchBefore > 0 || this.prefetchAfter > 0)) {
|
|
832
|
+
const prefetch = this.prefetchAroundTime;
|
|
833
|
+
prefetch?.call(this, currentTime).catch(() => { });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
getCurrentSynchronizationTime() {
|
|
837
|
+
return this.lastSynchronizedTime ?? this.video.currentTime + this.timeOffset;
|
|
838
|
+
}
|
|
839
|
+
getPresentationToken() {
|
|
840
|
+
return this.presentationToken;
|
|
841
|
+
}
|
|
842
|
+
isCurrentPresentation(token) {
|
|
843
|
+
return !this.disposed && token === this.presentationToken;
|
|
844
|
+
}
|
|
845
|
+
invalidatePresentation() {
|
|
846
|
+
this.presentationToken++;
|
|
847
|
+
}
|
|
848
|
+
watchPendingRender(index, pending) {
|
|
849
|
+
const token = this.getPresentationToken();
|
|
850
|
+
void pending.then(() => {
|
|
851
|
+
if (!this.isCurrentPresentation(token))
|
|
852
|
+
return;
|
|
853
|
+
if (this.findCurrentIndex(this.getCurrentSynchronizationTime()) !== index)
|
|
854
|
+
return;
|
|
855
|
+
this.lastRenderedIndex = -1;
|
|
856
|
+
this.renderPausedFrame();
|
|
857
|
+
}, () => { });
|
|
858
|
+
}
|
|
859
|
+
recordRenderPerformance(renderTime, capturedAt, countAsDropped = true) {
|
|
860
|
+
this.perfStats.lastRenderTime = renderTime;
|
|
861
|
+
this.perfStats.renderTimes.push(renderTime);
|
|
862
|
+
if (this.perfStats.renderTimes.length > 60) {
|
|
863
|
+
this.perfStats.renderTimes.shift();
|
|
864
|
+
}
|
|
865
|
+
this.perfStats.framesRendered++;
|
|
866
|
+
this.perfStats.fpsTimestamps.push(capturedAt);
|
|
867
|
+
if (countAsDropped && renderTime > 16.67) {
|
|
868
|
+
this.perfStats.framesDropped++;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
recordOffscreenRenderCompletion(time, index, status, width, height, compositionCount, startedAt) {
|
|
872
|
+
const completedAt = performance.now();
|
|
873
|
+
const renderTime = completedAt - startedAt;
|
|
874
|
+
this.recordRenderPerformance(renderTime, completedAt, false);
|
|
875
|
+
const cue = index >= 0 ? this.buildCueMetadata(index) : null;
|
|
876
|
+
this.emitCueChange(cue);
|
|
877
|
+
this.recordLastRenderInfo({
|
|
878
|
+
time,
|
|
879
|
+
index,
|
|
880
|
+
status,
|
|
881
|
+
backend: this.currentRendererBackend,
|
|
882
|
+
usingWorker: this.getCacheStats().usingWorker,
|
|
883
|
+
cacheHit: false,
|
|
884
|
+
renderDuration: Math.round(renderTime * 100) / 100,
|
|
885
|
+
frameWidth: width,
|
|
886
|
+
frameHeight: height,
|
|
887
|
+
compositionCount,
|
|
888
|
+
cue,
|
|
889
|
+
cache: this.getCacheStats(),
|
|
890
|
+
capturedAt: completedAt
|
|
891
|
+
});
|
|
892
|
+
this.emitEvent({ type: 'stats', stats: this.getStats() });
|
|
473
893
|
}
|
|
474
894
|
/** Render a subtitle frame to the canvas. */
|
|
475
895
|
renderFrame(time, index) {
|
|
476
896
|
if (!this.canvas) {
|
|
477
897
|
return { status: 'failed', data: null, warning: null };
|
|
478
898
|
}
|
|
899
|
+
if (this.useWorkerOffscreen) {
|
|
900
|
+
return this.renderFrameWorkerOffscreen(time, index);
|
|
901
|
+
}
|
|
479
902
|
// Get the data for this index
|
|
480
903
|
const data = index >= 0 ? this.renderAtIndex(index) : undefined;
|
|
481
904
|
// If data is undefined, it means async loading is in progress
|
|
@@ -489,7 +912,7 @@ class BaseVideoSubtitleRenderer {
|
|
|
489
912
|
return { status: 'pending', data: null, warning: null };
|
|
490
913
|
}
|
|
491
914
|
}
|
|
492
|
-
const renderIssue = index >= 0 ? this.getWorkerRendererState().renderIssues.get(index) ?? null : null;
|
|
915
|
+
const renderIssue = index >= 0 ? (this.getWorkerRendererState().renderIssues.get(index) ?? null) : null;
|
|
493
916
|
const warning = warningFromRenderIssue(renderIssue, { format: this.format, cueIndex: index });
|
|
494
917
|
// Use best available renderer
|
|
495
918
|
if (this.useWebGPU && this.webgpuRenderer) {
|
|
@@ -512,6 +935,83 @@ class BaseVideoSubtitleRenderer {
|
|
|
512
935
|
}
|
|
513
936
|
return { status: 'rendered', data, warning: null };
|
|
514
937
|
}
|
|
938
|
+
renderFrameWorkerOffscreen(time, index) {
|
|
939
|
+
const state = this.getWorkerRendererState();
|
|
940
|
+
if (!state.sessionId || !state.workerReady) {
|
|
941
|
+
queueMicrotask(() => this.fallbackFromWorkerOffscreen('Worker session is not ready.'));
|
|
942
|
+
return { status: 'pending', data: null, warning: null };
|
|
943
|
+
}
|
|
944
|
+
const token = this.getPresentationToken();
|
|
945
|
+
const sessionId = state.sessionId;
|
|
946
|
+
const startedAt = performance.now();
|
|
947
|
+
void sendToWorker({
|
|
948
|
+
type: 'presentOffscreen',
|
|
949
|
+
sessionId,
|
|
950
|
+
format: this.format,
|
|
951
|
+
index,
|
|
952
|
+
canvasWidth: this.canvasPixelWidth,
|
|
953
|
+
canvasHeight: this.canvasPixelHeight,
|
|
954
|
+
displaySettings: { ...this.displaySettings }
|
|
955
|
+
})
|
|
956
|
+
.then((response) => {
|
|
957
|
+
if (!this.isCurrentPresentation(token))
|
|
958
|
+
return;
|
|
959
|
+
if (response.type === 'error') {
|
|
960
|
+
this.recordOffscreenRenderCompletion(time, index, 'failed', null, null, 0, startedAt);
|
|
961
|
+
this.fallbackFromWorkerOffscreen(response.message);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
if (response.type !== 'offscreenPresented') {
|
|
965
|
+
this.recordOffscreenRenderCompletion(time, index, 'failed', null, null, 0, startedAt);
|
|
966
|
+
this.fallbackFromWorkerOffscreen(`Unexpected worker response: ${response.type}`);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (response.status === 'failed') {
|
|
970
|
+
const warning = warningFromRenderIssue(response.renderIssue?.trim() || null, {
|
|
971
|
+
format: this.format,
|
|
972
|
+
cueIndex: index
|
|
973
|
+
});
|
|
974
|
+
if (warning) {
|
|
975
|
+
this.emitWarning(warning);
|
|
976
|
+
}
|
|
977
|
+
this.recordOffscreenRenderCompletion(time, index, 'failed', response.width ?? null, response.height ?? null, response.compositionCount ?? 0, startedAt);
|
|
978
|
+
if (response.fatal) {
|
|
979
|
+
this.fallbackFromWorkerOffscreen(response.renderIssue || 'Offscreen presentation failed.');
|
|
980
|
+
}
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
if (response.status === 'cleared' || response.status === 'empty' || response.compositionCount === 0) {
|
|
984
|
+
this.lastRenderedData = null;
|
|
985
|
+
}
|
|
986
|
+
if (index >= 0) {
|
|
987
|
+
this.offscreenFrameMetadata.set(index, {
|
|
988
|
+
width: response.width ?? null,
|
|
989
|
+
height: response.height ?? null,
|
|
990
|
+
bounds: response.bounds ?? null,
|
|
991
|
+
compositionCount: response.compositionCount ?? 0
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
const warning = warningFromRenderIssue(response.renderIssue?.trim() || null, {
|
|
995
|
+
format: this.format,
|
|
996
|
+
cueIndex: index
|
|
997
|
+
});
|
|
998
|
+
if (warning) {
|
|
999
|
+
this.emitWarning(warning);
|
|
1000
|
+
}
|
|
1001
|
+
this.recordOffscreenRenderCompletion(time, index, response.status, response.width ?? null, response.height ?? null, response.compositionCount ?? 0, startedAt);
|
|
1002
|
+
})
|
|
1003
|
+
.catch((error) => {
|
|
1004
|
+
if (!this.isCurrentPresentation(token))
|
|
1005
|
+
return;
|
|
1006
|
+
this.recordOffscreenRenderCompletion(time, index, 'failed', null, null, 0, startedAt);
|
|
1007
|
+
this.fallbackFromWorkerOffscreen(error instanceof Error ? error.message : String(error));
|
|
1008
|
+
});
|
|
1009
|
+
if (index < 0) {
|
|
1010
|
+
this.lastRenderedData = null;
|
|
1011
|
+
return { status: 'cleared', data: null, warning: null };
|
|
1012
|
+
}
|
|
1013
|
+
return { status: 'pending', data: null, warning: null };
|
|
1014
|
+
}
|
|
515
1015
|
computeLayout(data) {
|
|
516
1016
|
if (!this.canvas) {
|
|
517
1017
|
return { scaleX: 1, scaleY: 1, shiftX: 0, shiftY: 0, opacity: this.displaySettings.opacity };
|
|
@@ -533,9 +1033,7 @@ class BaseVideoSubtitleRenderer {
|
|
|
533
1033
|
let frameShiftX = 0;
|
|
534
1034
|
let frameShiftY = 0;
|
|
535
1035
|
if (aspectMode !== 'stretch') {
|
|
536
|
-
const uniformScale = aspectMode === 'cover'
|
|
537
|
-
? Math.max(stretchScaleX, stretchScaleY)
|
|
538
|
-
: Math.min(stretchScaleX, stretchScaleY);
|
|
1036
|
+
const uniformScale = aspectMode === 'cover' ? Math.max(stretchScaleX, stretchScaleY) : Math.min(stretchScaleX, stretchScaleY);
|
|
539
1037
|
baseScaleX = uniformScale;
|
|
540
1038
|
baseScaleY = uniformScale;
|
|
541
1039
|
frameShiftX = (this.canvas.width - safeDataWidth * uniformScale) / 2;
|
|
@@ -643,10 +1141,9 @@ class BaseVideoSubtitleRenderer {
|
|
|
643
1141
|
/** Dispose of all resources. */
|
|
644
1142
|
dispose() {
|
|
645
1143
|
this.disposed = true;
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
}
|
|
1144
|
+
this.invalidatePresentation();
|
|
1145
|
+
this.frameScheduler?.stop();
|
|
1146
|
+
this.frameScheduler = null;
|
|
650
1147
|
this.resizeObserver?.disconnect();
|
|
651
1148
|
this.resizeObserver = null;
|
|
652
1149
|
if (this.loadedMetadataHandler) {
|
|
@@ -666,7 +1163,11 @@ class BaseVideoSubtitleRenderer {
|
|
|
666
1163
|
this.webgl2Renderer.destroy();
|
|
667
1164
|
this.webgl2Renderer = null;
|
|
668
1165
|
}
|
|
669
|
-
this.
|
|
1166
|
+
const state = this.getWorkerRendererState();
|
|
1167
|
+
if (this.useWorkerOffscreen && state.sessionId) {
|
|
1168
|
+
sendToWorker({ type: 'detachOffscreenCanvas', sessionId: state.sessionId }).catch(() => { });
|
|
1169
|
+
}
|
|
1170
|
+
this.removeOwnedCanvas();
|
|
670
1171
|
this.canvas = null;
|
|
671
1172
|
this.ctx = null;
|
|
672
1173
|
this.tempCanvas = null;
|
|
@@ -675,8 +1176,13 @@ class BaseVideoSubtitleRenderer {
|
|
|
675
1176
|
this.currentCueMetadata = null;
|
|
676
1177
|
this.parserMetadata = null;
|
|
677
1178
|
this.lastRenderInfo = null;
|
|
1179
|
+
this.offscreenFrameMetadata.clear();
|
|
678
1180
|
this.useWebGPU = false;
|
|
679
1181
|
this.useWebGL2 = false;
|
|
1182
|
+
this.useWorkerOffscreen = false;
|
|
1183
|
+
this.pendingWorkerOffscreen = false;
|
|
1184
|
+
this.offscreenTransferPending = false;
|
|
1185
|
+
this.offscreenAttachPromise = null;
|
|
680
1186
|
}
|
|
681
1187
|
}
|
|
682
1188
|
/**
|
|
@@ -686,6 +1192,8 @@ class BaseVideoSubtitleRenderer {
|
|
|
686
1192
|
export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
687
1193
|
pgsParser = null;
|
|
688
1194
|
state = createWorkerState();
|
|
1195
|
+
streamOperationQueue = Promise.resolve();
|
|
1196
|
+
streamGeneration = 0;
|
|
689
1197
|
onLoading;
|
|
690
1198
|
onLoaded;
|
|
691
1199
|
onError;
|
|
@@ -701,77 +1209,346 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
701
1209
|
try {
|
|
702
1210
|
this.emitEvent({ type: 'loading', format: 'pgs' });
|
|
703
1211
|
this.onLoading?.();
|
|
704
|
-
let arrayBuffer;
|
|
705
1212
|
if (this.subContent) {
|
|
706
|
-
|
|
1213
|
+
const data = new Uint8Array(this.subContent);
|
|
1214
|
+
this.emitLoadProgress('pgs', this.memoryProgress(data.byteLength), 0);
|
|
1215
|
+
await this.loadPgsBuffer(data, true);
|
|
1216
|
+
this.onLoaded?.();
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
if (!this.subUrl) {
|
|
1220
|
+
await this.beginPgsPushSession();
|
|
1221
|
+
this.onLoaded?.();
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
if (!this.streamingLoad) {
|
|
1225
|
+
const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(this.subUrl, {
|
|
1226
|
+
preferRange: this.rangeRequests,
|
|
1227
|
+
onProgress: (progress) => this.emitLoadProgress('pgs', progress, this.state.timestamps.length)
|
|
1228
|
+
});
|
|
1229
|
+
this.emitLoadProgress('pgs', { loaded: data.byteLength, total: total ?? data.byteLength, ratio: 1, rangeSupported, strategy }, 0);
|
|
1230
|
+
await this.loadPgsBuffer(data, false);
|
|
1231
|
+
this.onLoaded?.();
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
await this.loadPgsStreaming(this.subUrl);
|
|
1235
|
+
this.onLoaded?.();
|
|
1236
|
+
}
|
|
1237
|
+
catch (error) {
|
|
1238
|
+
const resolvedError = normalizeSubtitleError(error, { format: 'pgs' });
|
|
1239
|
+
this.emitEvent({ type: 'error', format: 'pgs', error: resolvedError });
|
|
1240
|
+
this.onError?.(resolvedError);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
applyPgsIndexState(metadata, timestamps, partial, usingWorker) {
|
|
1244
|
+
this.state.metadata = metadata;
|
|
1245
|
+
this.state.timestamps = timestamps;
|
|
1246
|
+
this.setParserMetadata(metadata);
|
|
1247
|
+
this.emitIndexed('pgs', metadata, partial);
|
|
1248
|
+
if (!this.isLoaded && timestamps.length > 0) {
|
|
1249
|
+
this.isLoaded = true;
|
|
1250
|
+
if (usingWorker) {
|
|
1251
|
+
this.state.workerReady = true;
|
|
1252
|
+
this.emitWorkerState(true, true, this.state.sessionId);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
async beginPgsPushSession() {
|
|
1257
|
+
if (this.state.useWorker) {
|
|
1258
|
+
try {
|
|
1259
|
+
this.state.sessionId = createWorkerSessionId();
|
|
1260
|
+
await getOrCreateWorker();
|
|
1261
|
+
this.emitWorkerState(true, false, this.state.sessionId);
|
|
1262
|
+
const response = await sendToWorker({ type: 'beginPgs', sessionId: this.state.sessionId });
|
|
1263
|
+
if (response.type === 'error')
|
|
1264
|
+
throw new Error(response.message);
|
|
1265
|
+
if (response.type !== 'pgsProgress')
|
|
1266
|
+
throw new Error('Unexpected PGS worker response');
|
|
1267
|
+
this.applyPgsIndexState(response.metadata, response.timestamps, true, true);
|
|
1268
|
+
this.state.workerReady = true;
|
|
1269
|
+
this.isLoaded = true;
|
|
1270
|
+
this.emitWorkerState(true, true, this.state.sessionId);
|
|
1271
|
+
return;
|
|
707
1272
|
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
1273
|
+
catch (workerError) {
|
|
1274
|
+
this.state.useWorker = false;
|
|
1275
|
+
this.state.workerReady = false;
|
|
1276
|
+
this.state.sessionId = null;
|
|
1277
|
+
this.emitWorkerState(false, false, null, true);
|
|
1278
|
+
this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'PGS worker initialization failed, falling back to main-thread rendering.', {
|
|
1279
|
+
format: 'pgs',
|
|
1280
|
+
details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
|
|
1281
|
+
}));
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
await this.yieldToMain();
|
|
1285
|
+
this.pgsParser = new PgsParser({ debug: this.debug, onWarning: (warning) => this.emitWarning(warning) });
|
|
1286
|
+
this.pgsParser.reset();
|
|
1287
|
+
const metadata = this.pgsParser.getMetadata();
|
|
1288
|
+
this.applyPgsIndexState(metadata, this.pgsParser.getTimestamps(), true, false);
|
|
1289
|
+
this.isLoaded = true;
|
|
1290
|
+
}
|
|
1291
|
+
enqueueStreamOperation(operation) {
|
|
1292
|
+
const run = async () => {
|
|
1293
|
+
try {
|
|
1294
|
+
await this.waitUntilInitialized();
|
|
1295
|
+
return await operation();
|
|
1296
|
+
}
|
|
1297
|
+
catch (error) {
|
|
1298
|
+
const resolvedError = normalizeSubtitleError(error, { format: 'pgs' });
|
|
1299
|
+
this.emitEvent({ type: 'error', format: 'pgs', error: resolvedError });
|
|
1300
|
+
this.onError?.(resolvedError);
|
|
1301
|
+
throw resolvedError;
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
const result = this.streamOperationQueue.then(run, run);
|
|
1305
|
+
this.streamOperationQueue = result.then(() => undefined, () => undefined);
|
|
1306
|
+
return result;
|
|
1307
|
+
}
|
|
1308
|
+
/** Append a chunk to the active PGS stream and return the number of newly indexed cues. */
|
|
1309
|
+
append(data) {
|
|
1310
|
+
return this.enqueueStreamOperation(async () => {
|
|
1311
|
+
const chunk = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
1312
|
+
if (chunk.byteLength === 0)
|
|
1313
|
+
return 0;
|
|
1314
|
+
let added;
|
|
1315
|
+
if (this.state.useWorker && this.state.workerReady && this.state.sessionId) {
|
|
1316
|
+
const response = await sendToWorker({
|
|
1317
|
+
type: 'appendPgs',
|
|
1318
|
+
sessionId: this.state.sessionId,
|
|
1319
|
+
data: createTransferableBuffer(chunk, true)
|
|
1320
|
+
});
|
|
1321
|
+
if (response.type === 'error')
|
|
1322
|
+
throw new Error(response.message);
|
|
1323
|
+
if (response.type !== 'pgsProgress')
|
|
1324
|
+
throw new Error('Unexpected PGS worker response');
|
|
1325
|
+
added = response.added;
|
|
1326
|
+
if (added > 0) {
|
|
1327
|
+
this.applyPgsIndexState(response.metadata, response.timestamps, true, true);
|
|
1328
|
+
}
|
|
1329
|
+
else {
|
|
1330
|
+
this.state.metadata = response.metadata;
|
|
1331
|
+
this.state.timestamps = response.timestamps;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
else {
|
|
1335
|
+
if (!this.pgsParser)
|
|
1336
|
+
throw new Error('PGS parser is not initialized');
|
|
1337
|
+
added = this.pgsParser.feed(chunk);
|
|
1338
|
+
if (added > 0) {
|
|
1339
|
+
this.applyPgsIndexState(this.pgsParser.getMetadata(), this.pgsParser.getTimestamps(), true, false);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
if (added > 0)
|
|
1343
|
+
this.refreshStreamPresentation();
|
|
1344
|
+
return added;
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
/** Flush incomplete trailing PGS input and return the total indexed cue count. */
|
|
1348
|
+
flush() {
|
|
1349
|
+
return this.enqueueStreamOperation(async () => {
|
|
1350
|
+
let count;
|
|
1351
|
+
if (this.state.useWorker && this.state.workerReady && this.state.sessionId) {
|
|
1352
|
+
const response = await sendToWorker({ type: 'finishPgs', sessionId: this.state.sessionId });
|
|
1353
|
+
if (response.type === 'error')
|
|
1354
|
+
throw new Error(response.message);
|
|
1355
|
+
if (response.type !== 'pgsProgress')
|
|
1356
|
+
throw new Error('Unexpected PGS worker response');
|
|
1357
|
+
count = response.count;
|
|
1358
|
+
this.applyPgsIndexState(response.metadata, response.timestamps, false, true);
|
|
713
1359
|
}
|
|
714
1360
|
else {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
1361
|
+
if (!this.pgsParser)
|
|
1362
|
+
throw new Error('PGS parser is not initialized');
|
|
1363
|
+
count = this.pgsParser.finishFeed();
|
|
1364
|
+
this.applyPgsIndexState(this.pgsParser.getMetadata(), this.pgsParser.getTimestamps(), false, false);
|
|
1365
|
+
}
|
|
1366
|
+
this.refreshStreamPresentation();
|
|
1367
|
+
return count;
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
/** Reset the PGS stream while keeping the renderer and canvas ready for more chunks. */
|
|
1371
|
+
reset() {
|
|
1372
|
+
return this.enqueueStreamOperation(async () => {
|
|
1373
|
+
this.streamGeneration++;
|
|
1374
|
+
try {
|
|
1375
|
+
if (this.state.useWorker && this.state.workerReady && this.state.sessionId) {
|
|
1376
|
+
const response = await sendToWorker({ type: 'resetPgs', sessionId: this.state.sessionId });
|
|
1377
|
+
if (response.type === 'error')
|
|
1378
|
+
throw new Error(response.message);
|
|
1379
|
+
if (response.type !== 'pgsProgress')
|
|
1380
|
+
throw new Error('Unexpected PGS worker response');
|
|
1381
|
+
this.applyPgsIndexState(response.metadata, response.timestamps, true, true);
|
|
1382
|
+
}
|
|
1383
|
+
else {
|
|
1384
|
+
if (!this.pgsParser)
|
|
1385
|
+
throw new Error('PGS parser is not initialized');
|
|
1386
|
+
this.pgsParser.reset();
|
|
1387
|
+
this.applyPgsIndexState(this.pgsParser.getMetadata(), this.pgsParser.getTimestamps(), true, false);
|
|
1388
|
+
}
|
|
1389
|
+
this.state.frameCache.clear();
|
|
1390
|
+
this.state.renderIssues.clear();
|
|
1391
|
+
this.state.pendingRenders.clear();
|
|
1392
|
+
this.clearStreamPresentation();
|
|
1393
|
+
this.emitCacheChange(0, 0);
|
|
1394
|
+
}
|
|
1395
|
+
finally {
|
|
1396
|
+
this.streamGeneration++;
|
|
1397
|
+
}
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
async loadPgsBuffer(data, preserveSource) {
|
|
1401
|
+
if (this.state.useWorker) {
|
|
1402
|
+
try {
|
|
1403
|
+
this.state.sessionId = createWorkerSessionId();
|
|
1404
|
+
await getOrCreateWorker();
|
|
1405
|
+
this.emitWorkerState(true, false, this.state.sessionId);
|
|
1406
|
+
const transferableData = createTransferableBuffer(data, preserveSource);
|
|
1407
|
+
const loadResponse = await sendToWorker({
|
|
1408
|
+
type: 'loadPgs',
|
|
1409
|
+
sessionId: this.state.sessionId,
|
|
1410
|
+
data: transferableData
|
|
1411
|
+
});
|
|
1412
|
+
if (loadResponse.type === 'pgsLoaded') {
|
|
1413
|
+
this.state.workerReady = true;
|
|
1414
|
+
this.state.metadata = loadResponse.metadata;
|
|
1415
|
+
this.state.timestamps = loadResponse.timestamps;
|
|
1416
|
+
this.isLoaded = true;
|
|
1417
|
+
this.setParserMetadata(loadResponse.metadata);
|
|
1418
|
+
this.emitIndexed('pgs', loadResponse.metadata, false);
|
|
1419
|
+
this.emitWorkerState(true, true, this.state.sessionId);
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
else if (loadResponse.type === 'error') {
|
|
1423
|
+
throw new Error(loadResponse.message);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
catch (workerError) {
|
|
1427
|
+
this.state.useWorker = false;
|
|
1428
|
+
this.emitWorkerState(false, false, this.state.sessionId, true);
|
|
1429
|
+
this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'PGS worker initialization failed, falling back to main-thread rendering.', {
|
|
1430
|
+
format: 'pgs',
|
|
1431
|
+
details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
|
|
1432
|
+
}));
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
await this.loadOnMainThread(data);
|
|
1436
|
+
}
|
|
1437
|
+
async loadPgsStreaming(url) {
|
|
1438
|
+
let usedWorker = false;
|
|
1439
|
+
let indexedOnce = false;
|
|
1440
|
+
if (this.state.useWorker) {
|
|
1441
|
+
try {
|
|
1442
|
+
this.state.sessionId = createWorkerSessionId();
|
|
1443
|
+
await getOrCreateWorker();
|
|
1444
|
+
this.emitWorkerState(true, false, this.state.sessionId);
|
|
1445
|
+
const begin = await sendToWorker({ type: 'beginPgs', sessionId: this.state.sessionId });
|
|
1446
|
+
if (begin.type === 'error')
|
|
1447
|
+
throw new Error(begin.message);
|
|
1448
|
+
usedWorker = true;
|
|
1449
|
+
}
|
|
1450
|
+
catch (workerError) {
|
|
1451
|
+
this.state.useWorker = false;
|
|
1452
|
+
usedWorker = false;
|
|
1453
|
+
this.emitWorkerState(false, false, this.state.sessionId, true);
|
|
1454
|
+
this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'PGS worker initialization failed, falling back to main-thread rendering.', {
|
|
1455
|
+
format: 'pgs',
|
|
1456
|
+
details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
|
|
1457
|
+
}));
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
if (!usedWorker) {
|
|
1461
|
+
await this.yieldToMain();
|
|
1462
|
+
this.pgsParser = new PgsParser({ debug: this.debug, onWarning: (warning) => this.emitWarning(warning) });
|
|
1463
|
+
this.pgsParser.reset();
|
|
1464
|
+
}
|
|
1465
|
+
try {
|
|
1466
|
+
const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(url, {
|
|
1467
|
+
preferRange: this.rangeRequests,
|
|
1468
|
+
onProgress: (progress) => this.emitLoadProgress('pgs', progress, this.state.timestamps.length)
|
|
1469
|
+
}, async (chunk, progress) => {
|
|
1470
|
+
if (chunk.byteLength === 0)
|
|
1471
|
+
return;
|
|
1472
|
+
if (usedWorker && this.state.sessionId) {
|
|
1473
|
+
const transferable = createTransferableBuffer(chunk, true);
|
|
1474
|
+
const response = await sendToWorker({
|
|
1475
|
+
type: 'appendPgs',
|
|
726
1476
|
sessionId: this.state.sessionId,
|
|
727
|
-
data:
|
|
1477
|
+
data: transferable
|
|
728
1478
|
});
|
|
729
|
-
if (
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1479
|
+
if (response.type === 'pgsProgress') {
|
|
1480
|
+
if (response.added > 0 || !indexedOnce) {
|
|
1481
|
+
this.applyPgsIndexState(response.metadata, response.timestamps, true, true);
|
|
1482
|
+
indexedOnce = true;
|
|
1483
|
+
}
|
|
1484
|
+
else {
|
|
1485
|
+
this.state.timestamps = response.timestamps;
|
|
1486
|
+
this.state.metadata = response.metadata;
|
|
1487
|
+
}
|
|
738
1488
|
}
|
|
739
|
-
else if (
|
|
740
|
-
throw new Error(
|
|
1489
|
+
else if (response.type === 'error') {
|
|
1490
|
+
throw new Error(response.message);
|
|
741
1491
|
}
|
|
742
1492
|
}
|
|
743
|
-
|
|
744
|
-
this.
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
}));
|
|
750
|
-
if (!this.subContent && this.subUrl && data.byteLength === 0) {
|
|
751
|
-
const response = await fetch(this.subUrl);
|
|
752
|
-
if (!response.ok)
|
|
753
|
-
throw new Error(`Failed to fetch subtitle: ${response.status}`);
|
|
754
|
-
data = new Uint8Array(await response.arrayBuffer());
|
|
1493
|
+
else if (this.pgsParser) {
|
|
1494
|
+
const added = this.pgsParser.feed(chunk);
|
|
1495
|
+
if (added > 0 || !indexedOnce) {
|
|
1496
|
+
const metadata = this.pgsParser.getMetadata();
|
|
1497
|
+
this.applyPgsIndexState(metadata, this.pgsParser.getTimestamps(), true, false);
|
|
1498
|
+
indexedOnce = true;
|
|
755
1499
|
}
|
|
756
1500
|
}
|
|
1501
|
+
this.emitLoadProgress('pgs', progress, this.state.timestamps.length);
|
|
1502
|
+
});
|
|
1503
|
+
if (usedWorker && this.state.sessionId) {
|
|
1504
|
+
const finish = await sendToWorker({ type: 'finishPgs', sessionId: this.state.sessionId });
|
|
1505
|
+
if (finish.type === 'pgsProgress') {
|
|
1506
|
+
this.applyPgsIndexState(finish.metadata, finish.timestamps, false, true);
|
|
1507
|
+
this.state.workerReady = true;
|
|
1508
|
+
this.isLoaded = true;
|
|
1509
|
+
this.emitWorkerState(true, true, this.state.sessionId);
|
|
1510
|
+
}
|
|
1511
|
+
else if (finish.type === 'error') {
|
|
1512
|
+
throw new Error(finish.message);
|
|
1513
|
+
}
|
|
757
1514
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1515
|
+
else if (this.pgsParser) {
|
|
1516
|
+
this.pgsParser.finishFeed();
|
|
1517
|
+
const metadata = this.pgsParser.getMetadata();
|
|
1518
|
+
this.state.timestamps = this.pgsParser.getTimestamps();
|
|
1519
|
+
this.state.metadata = metadata;
|
|
1520
|
+
this.isLoaded = true;
|
|
1521
|
+
this.setParserMetadata(metadata);
|
|
1522
|
+
this.emitIndexed('pgs', metadata, false);
|
|
1523
|
+
if (metadata.cueCount === 0) {
|
|
1524
|
+
this.state.renderIssues.set(-1, 'INVALID_SUBTITLE_DATA');
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
this.emitLoadProgress('pgs', {
|
|
1528
|
+
loaded: data.byteLength,
|
|
1529
|
+
total: total ?? data.byteLength,
|
|
1530
|
+
ratio: 1,
|
|
1531
|
+
rangeSupported,
|
|
1532
|
+
strategy: strategy
|
|
1533
|
+
}, this.state.timestamps.length);
|
|
761
1534
|
}
|
|
762
1535
|
catch (error) {
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
1536
|
+
if (usedWorker) {
|
|
1537
|
+
this.state.useWorker = false;
|
|
1538
|
+
this.emitWorkerState(false, false, this.state.sessionId, true);
|
|
1539
|
+
}
|
|
1540
|
+
this.emitWarning(createSubtitleWarning('RANGE_FALLBACK', 'Progressive PGS load failed; retrying with a full buffer fetch.', {
|
|
1541
|
+
format: 'pgs',
|
|
1542
|
+
details: { reason: error instanceof Error ? error.message : String(error) }
|
|
1543
|
+
}));
|
|
1544
|
+
const { data } = await fetchSubtitleAsset(url, { preferRange: this.rangeRequests });
|
|
1545
|
+
await this.loadPgsBuffer(data, false);
|
|
766
1546
|
}
|
|
767
1547
|
}
|
|
768
1548
|
async loadOnMainThread(data) {
|
|
769
|
-
// Yield to browser before heavy parsing
|
|
770
1549
|
await this.yieldToMain();
|
|
771
1550
|
this.pgsParser = new PgsParser({ debug: this.debug, onWarning: (warning) => this.emitWarning(warning) });
|
|
772
|
-
// Parse in a microtask to allow UI to update
|
|
773
1551
|
await new Promise((resolve) => {
|
|
774
|
-
// Use requestIdleCallback if available, otherwise setTimeout
|
|
775
1552
|
const scheduleTask = typeof requestIdleCallback !== 'undefined'
|
|
776
1553
|
? (cb) => requestIdleCallback(() => cb(), { timeout: 1000 })
|
|
777
1554
|
: (cb) => setTimeout(cb, 0);
|
|
@@ -781,6 +1558,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
781
1558
|
this.state.metadata = this.pgsParser.getMetadata();
|
|
782
1559
|
this.isLoaded = true;
|
|
783
1560
|
this.setParserMetadata(this.state.metadata);
|
|
1561
|
+
this.emitIndexed('pgs', this.state.metadata, false);
|
|
784
1562
|
if (count === 0) {
|
|
785
1563
|
this.state.renderIssues.set(-1, 'INVALID_SUBTITLE_DATA');
|
|
786
1564
|
}
|
|
@@ -817,6 +1595,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
817
1595
|
}
|
|
818
1596
|
if (this.state.useWorker && this.state.workerReady) {
|
|
819
1597
|
if (!this.state.pendingRenders.has(index)) {
|
|
1598
|
+
const generation = this.streamGeneration;
|
|
820
1599
|
const renderTask = sendToWorker({
|
|
821
1600
|
type: 'renderPgsAtIndex',
|
|
822
1601
|
sessionId: this.state.sessionId,
|
|
@@ -834,15 +1613,14 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
834
1613
|
this.state.pendingRenders.set(index, renderPromise);
|
|
835
1614
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
836
1615
|
renderTask.then(({ frame, renderIssue }) => {
|
|
1616
|
+
if (generation !== this.streamGeneration || this.disposed)
|
|
1617
|
+
return;
|
|
837
1618
|
setCachedFrame(this.state, index, frame, renderIssue);
|
|
838
1619
|
this.state.pendingRenders.delete(index);
|
|
839
1620
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
840
|
-
// Force re-render on next frame by resetting lastRenderedIndex
|
|
841
|
-
if (this.findCurrentIndex(this.video.currentTime + this.timeOffset) === index) {
|
|
842
|
-
this.lastRenderedIndex = -1;
|
|
843
|
-
}
|
|
844
1621
|
});
|
|
845
1622
|
}
|
|
1623
|
+
this.watchPendingRender(index, this.state.pendingRenders.get(index));
|
|
846
1624
|
// Return undefined to indicate async loading in progress
|
|
847
1625
|
return undefined;
|
|
848
1626
|
}
|
|
@@ -861,6 +1639,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
861
1639
|
const startTime = this.state.timestamps[index];
|
|
862
1640
|
const endTime = this.state.timestamps[index + 1] ?? startTime + 5000;
|
|
863
1641
|
const frame = this.state.frameCache.get(index) ?? null;
|
|
1642
|
+
const offscreenFrame = this.getOffscreenFrameMetadata(index);
|
|
864
1643
|
return {
|
|
865
1644
|
index,
|
|
866
1645
|
format: 'pgs',
|
|
@@ -869,8 +1648,8 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
869
1648
|
duration: Math.max(0, endTime - startTime),
|
|
870
1649
|
screenWidth: metadata.screenWidth,
|
|
871
1650
|
screenHeight: metadata.screenHeight,
|
|
872
|
-
bounds: frame ? getSubtitleBounds(frame) : null,
|
|
873
|
-
compositionCount: frame?.compositionData.length ?? 0
|
|
1651
|
+
bounds: frame ? getSubtitleBounds(frame) : (offscreenFrame?.bounds ?? null),
|
|
1652
|
+
compositionCount: frame?.compositionData.length ?? offscreenFrame?.compositionCount ?? 0
|
|
874
1653
|
};
|
|
875
1654
|
}
|
|
876
1655
|
isPendingRender(index) {
|
|
@@ -891,15 +1670,18 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
891
1670
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
892
1671
|
}
|
|
893
1672
|
clearFrameCache() {
|
|
1673
|
+
this.invalidatePresentation();
|
|
894
1674
|
this.state.frameCache.clear();
|
|
895
1675
|
this.state.renderIssues.clear();
|
|
896
1676
|
this.state.pendingRenders.clear();
|
|
1677
|
+
this.clearOffscreenFrameMetadata();
|
|
897
1678
|
this.lastRenderedIndex = -1;
|
|
898
1679
|
if (this.state.useWorker && this.state.workerReady) {
|
|
899
1680
|
sendToWorker({ type: 'clearPgsCache', sessionId: this.state.sessionId }).catch(() => { });
|
|
900
1681
|
}
|
|
901
1682
|
this.pgsParser?.clearCache();
|
|
902
1683
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
1684
|
+
this.renderPausedFrame();
|
|
903
1685
|
}
|
|
904
1686
|
async prefetchRange(startIndex, endIndex) {
|
|
905
1687
|
const safeStart = Math.max(0, Math.min(startIndex, endIndex));
|
|
@@ -931,6 +1713,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
931
1713
|
};
|
|
932
1714
|
}
|
|
933
1715
|
dispose() {
|
|
1716
|
+
this.streamGeneration++;
|
|
934
1717
|
super.dispose();
|
|
935
1718
|
this.state.frameCache.clear();
|
|
936
1719
|
this.state.renderIssues.clear();
|
|
@@ -962,9 +1745,9 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
962
1745
|
pendingIndexLookup = null;
|
|
963
1746
|
constructor(options) {
|
|
964
1747
|
super(options, 'vobsub');
|
|
965
|
-
this.idxUrl =
|
|
966
|
-
|
|
967
|
-
|
|
1748
|
+
this.idxUrl =
|
|
1749
|
+
options.idxUrl ||
|
|
1750
|
+
(options.subUrl && /\.sub$/i.test(options.subUrl) ? options.subUrl.replace(/\.sub$/i, '.idx') : undefined);
|
|
968
1751
|
this.idxContent = options.idxContent;
|
|
969
1752
|
this.fileName = options.fileName;
|
|
970
1753
|
this.onLoading = options.onLoading;
|
|
@@ -977,125 +1760,251 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
977
1760
|
try {
|
|
978
1761
|
this.emitEvent({ type: 'loading', format: 'vobsub' });
|
|
979
1762
|
this.onLoading?.();
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1763
|
+
const useMksSource = !this.idxContent &&
|
|
1764
|
+
!this.idxUrl &&
|
|
1765
|
+
isMksSource({
|
|
1766
|
+
subData: this.subContent,
|
|
1767
|
+
fileName: this.fileName,
|
|
1768
|
+
subUrl: this.subUrl
|
|
1769
|
+
});
|
|
1770
|
+
if (this.subContent && (useMksSource || this.idxContent)) {
|
|
1771
|
+
const subData = new Uint8Array(this.subContent);
|
|
1772
|
+
this.emitLoadProgress('vobsub', this.memoryProgress(subData.byteLength), 0);
|
|
1773
|
+
await this.loadVobSubBuffer(subData, this.idxContent, useMksSource, true);
|
|
1774
|
+
this.onLoaded?.();
|
|
1775
|
+
return;
|
|
990
1776
|
}
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1777
|
+
if (useMksSource) {
|
|
1778
|
+
await this.loadVobSubMksStreaming();
|
|
1779
|
+
this.onLoaded?.();
|
|
1780
|
+
return;
|
|
994
1781
|
}
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1782
|
+
await this.loadVobSubIdxSubStreaming();
|
|
1783
|
+
this.onLoaded?.();
|
|
1784
|
+
}
|
|
1785
|
+
catch (error) {
|
|
1786
|
+
const resolvedError = normalizeSubtitleError(error, { format: 'vobsub' });
|
|
1787
|
+
this.emitEvent({ type: 'error', format: 'vobsub', error: resolvedError });
|
|
1788
|
+
this.onError?.(resolvedError);
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
applyVobSubIndexState(metadata, timestamps, partial, renderable, usingWorker) {
|
|
1792
|
+
this.state.metadata = metadata;
|
|
1793
|
+
this.state.timestamps = timestamps;
|
|
1794
|
+
this.setParserMetadata(metadata);
|
|
1795
|
+
this.emitIndexed('vobsub', metadata, partial);
|
|
1796
|
+
if (renderable && !this.isLoaded && timestamps.length > 0) {
|
|
1797
|
+
this.isLoaded = true;
|
|
1798
|
+
if (usingWorker) {
|
|
1799
|
+
this.state.workerReady = true;
|
|
1800
|
+
this.emitWorkerState(true, true, this.state.sessionId);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
async ensureVobSubWorkerSession() {
|
|
1805
|
+
if (!this.state.useWorker)
|
|
1806
|
+
return false;
|
|
1807
|
+
try {
|
|
1808
|
+
this.state.sessionId = createWorkerSessionId();
|
|
1809
|
+
await getOrCreateWorker();
|
|
1810
|
+
this.emitWorkerState(true, false, this.state.sessionId);
|
|
1811
|
+
return true;
|
|
1812
|
+
}
|
|
1813
|
+
catch (workerError) {
|
|
1814
|
+
this.state.useWorker = false;
|
|
1815
|
+
this.emitWorkerState(false, false, this.state.sessionId, true);
|
|
1816
|
+
this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'VobSub worker initialization failed, falling back to main-thread rendering.', {
|
|
1817
|
+
format: 'vobsub',
|
|
1818
|
+
details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
|
|
1819
|
+
}));
|
|
1820
|
+
return false;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
async loadVobSubBuffer(subData, idxData, useMksSource, preserveSource) {
|
|
1824
|
+
if (await this.ensureVobSubWorkerSession()) {
|
|
1825
|
+
try {
|
|
1826
|
+
const transferableSubData = createTransferableBuffer(subData, preserveSource);
|
|
1827
|
+
const loadResponse = await sendToWorker(useMksSource
|
|
1828
|
+
? {
|
|
1829
|
+
type: 'loadVobSubMks',
|
|
1830
|
+
sessionId: this.state.sessionId,
|
|
1831
|
+
subData: transferableSubData
|
|
1832
|
+
}
|
|
1833
|
+
: {
|
|
1834
|
+
type: 'loadVobSub',
|
|
1835
|
+
sessionId: this.state.sessionId,
|
|
1836
|
+
idxContent: idxData,
|
|
1837
|
+
subData: transferableSubData
|
|
1838
|
+
});
|
|
1839
|
+
if (loadResponse.type === 'vobSubLoaded') {
|
|
1840
|
+
if (!useMksSource && idxData && loadResponse.count === 0) {
|
|
1841
|
+
throw createSubtitleDiagnosticError('BAD_IDX', 'IDX metadata did not yield any subtitle timestamps.', {
|
|
1842
|
+
format: 'vobsub'
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
this.state.workerReady = true;
|
|
1846
|
+
this.state.metadata = loadResponse.metadata;
|
|
1847
|
+
this.state.timestamps = loadResponse.timestamps;
|
|
1848
|
+
this.isLoaded = true;
|
|
1849
|
+
this.setParserMetadata(loadResponse.metadata);
|
|
1850
|
+
this.emitIndexed('vobsub', loadResponse.metadata, false);
|
|
1851
|
+
this.emitWorkerState(true, true, this.state.sessionId);
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
else if (loadResponse.type === 'error') {
|
|
1855
|
+
throw new Error(loadResponse.message);
|
|
1000
1856
|
}
|
|
1001
|
-
promises.push(fetch(this.subUrl)
|
|
1002
|
-
.then((r) => {
|
|
1003
|
-
if (!r.ok)
|
|
1004
|
-
throw new Error(`Failed to fetch .sub file: ${r.status}`);
|
|
1005
|
-
return r.arrayBuffer();
|
|
1006
|
-
})
|
|
1007
|
-
.then((b) => {
|
|
1008
|
-
subArrayBuffer = b;
|
|
1009
|
-
}));
|
|
1010
1857
|
}
|
|
1011
|
-
|
|
1012
|
-
if (
|
|
1013
|
-
throw
|
|
1858
|
+
catch (workerError) {
|
|
1859
|
+
if (workerError instanceof SubtitleDiagnosticError && workerError.code === 'BAD_IDX') {
|
|
1860
|
+
throw workerError;
|
|
1014
1861
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
})
|
|
1021
|
-
.then((t) => {
|
|
1022
|
-
idxData = t;
|
|
1862
|
+
this.state.useWorker = false;
|
|
1863
|
+
this.emitWorkerState(false, false, this.state.sessionId, true);
|
|
1864
|
+
this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'VobSub worker load failed, falling back to main-thread rendering.', {
|
|
1865
|
+
format: 'vobsub',
|
|
1866
|
+
details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
|
|
1023
1867
|
}));
|
|
1024
1868
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1869
|
+
}
|
|
1870
|
+
await this.loadOnMainThread(subData, idxData, useMksSource);
|
|
1871
|
+
}
|
|
1872
|
+
async loadVobSubMksStreaming() {
|
|
1873
|
+
if (this.subContent) {
|
|
1874
|
+
await this.loadVobSubBuffer(new Uint8Array(this.subContent), undefined, true, true);
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
if (!this.subUrl) {
|
|
1878
|
+
throw createSubtitleDiagnosticError('MISSING_INPUT', 'No SUB content or URL provided.', { format: 'vobsub' });
|
|
1879
|
+
}
|
|
1880
|
+
const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(this.subUrl, {
|
|
1881
|
+
preferRange: this.rangeRequests && this.streamingLoad,
|
|
1882
|
+
onProgress: (progress) => this.emitLoadProgress('vobsub', progress, this.state.timestamps.length)
|
|
1883
|
+
});
|
|
1884
|
+
this.emitLoadProgress('vobsub', {
|
|
1885
|
+
loaded: data.byteLength,
|
|
1886
|
+
total: total ?? data.byteLength,
|
|
1887
|
+
ratio: 1,
|
|
1888
|
+
rangeSupported,
|
|
1889
|
+
strategy
|
|
1890
|
+
}, 0);
|
|
1891
|
+
await this.loadVobSubBuffer(data, undefined, true, false);
|
|
1892
|
+
}
|
|
1893
|
+
async loadVobSubIdxSubStreaming() {
|
|
1894
|
+
let idxData = this.idxContent;
|
|
1895
|
+
if (!idxData) {
|
|
1896
|
+
if (!this.idxUrl) {
|
|
1897
|
+
throw createSubtitleDiagnosticError('MISSING_INPUT', 'No IDX content or URL provided.', { format: 'vobsub' });
|
|
1898
|
+
}
|
|
1899
|
+
idxData = await fetchSubtitleText(this.idxUrl, {
|
|
1900
|
+
onProgress: (progress) => this.emitLoadProgress('vobsub', progress, 0)
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
let usedWorker = await this.ensureVobSubWorkerSession();
|
|
1904
|
+
if (usedWorker && this.state.sessionId) {
|
|
1905
|
+
try {
|
|
1906
|
+
const idxResponse = await sendToWorker({
|
|
1907
|
+
type: 'loadVobSubIdx',
|
|
1908
|
+
sessionId: this.state.sessionId,
|
|
1909
|
+
idxContent: idxData
|
|
1910
|
+
});
|
|
1911
|
+
if (idxResponse.type === 'vobSubProgress') {
|
|
1912
|
+
if (idxResponse.count === 0) {
|
|
1913
|
+
throw createSubtitleDiagnosticError('BAD_IDX', 'IDX metadata did not yield any subtitle timestamps.', {
|
|
1914
|
+
format: 'vobsub'
|
|
1049
1915
|
});
|
|
1050
|
-
if (loadResponse.type === 'vobSubLoaded') {
|
|
1051
|
-
if (!useMksSource && idxData && loadResponse.count === 0) {
|
|
1052
|
-
throw createSubtitleDiagnosticError('BAD_IDX', 'IDX metadata did not yield any subtitle timestamps.', {
|
|
1053
|
-
format: 'vobsub'
|
|
1054
|
-
});
|
|
1055
|
-
}
|
|
1056
|
-
this.state.workerReady = true;
|
|
1057
|
-
this.state.metadata = loadResponse.metadata;
|
|
1058
|
-
this.state.timestamps = loadResponse.timestamps;
|
|
1059
|
-
this.isLoaded = true;
|
|
1060
|
-
this.setParserMetadata(loadResponse.metadata);
|
|
1061
|
-
this.emitWorkerState(true, true, this.state.sessionId);
|
|
1062
|
-
this.onLoaded?.();
|
|
1063
|
-
return; // Success, don't fall through to main thread
|
|
1064
|
-
}
|
|
1065
|
-
else if (loadResponse.type === 'error') {
|
|
1066
|
-
throw new Error(loadResponse.message);
|
|
1067
1916
|
}
|
|
1917
|
+
this.applyVobSubIndexState(idxResponse.metadata, idxResponse.timestamps, true, false, true);
|
|
1918
|
+
this.state.workerReady = true;
|
|
1919
|
+
this.emitWorkerState(true, true, this.state.sessionId);
|
|
1068
1920
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
this.emitWorkerState(false, false, this.state.sessionId, true);
|
|
1072
|
-
this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'VobSub worker initialization failed, falling back to main-thread rendering.', {
|
|
1073
|
-
format: 'vobsub',
|
|
1074
|
-
details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
|
|
1075
|
-
}));
|
|
1076
|
-
if (!this.subContent && this.subUrl && subData.byteLength === 0) {
|
|
1077
|
-
const response = await fetch(this.subUrl);
|
|
1078
|
-
if (!response.ok)
|
|
1079
|
-
throw new Error(`Failed to fetch .sub file: ${response.status}`);
|
|
1080
|
-
subData = new Uint8Array(await response.arrayBuffer());
|
|
1081
|
-
}
|
|
1921
|
+
else if (idxResponse.type === 'error') {
|
|
1922
|
+
throw new Error(idxResponse.message);
|
|
1082
1923
|
}
|
|
1083
1924
|
}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1925
|
+
catch (error) {
|
|
1926
|
+
if (error instanceof SubtitleDiagnosticError && error.code === 'BAD_IDX') {
|
|
1927
|
+
throw error;
|
|
1928
|
+
}
|
|
1929
|
+
usedWorker = false;
|
|
1930
|
+
this.state.useWorker = false;
|
|
1931
|
+
this.emitWorkerState(false, false, this.state.sessionId, true);
|
|
1932
|
+
this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'VobSub worker IDX load failed, falling back to main-thread rendering.', {
|
|
1933
|
+
format: 'vobsub',
|
|
1934
|
+
details: { reason: error instanceof Error ? error.message : String(error) }
|
|
1935
|
+
}));
|
|
1936
|
+
}
|
|
1087
1937
|
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
this.
|
|
1091
|
-
|
|
1938
|
+
if (!usedWorker) {
|
|
1939
|
+
await this.yieldToMain();
|
|
1940
|
+
this.vobsubParser = new VobSubParserLowLevel({
|
|
1941
|
+
debug: this.debug,
|
|
1942
|
+
onWarning: (warning) => this.emitWarning(warning)
|
|
1943
|
+
});
|
|
1944
|
+
this.vobsubParser.loadFromIdx(idxData);
|
|
1945
|
+
this.applyVobSubIndexState(this.vobsubParser.getMetadata(), this.vobsubParser.getTimestamps(), true, false, false);
|
|
1946
|
+
}
|
|
1947
|
+
let subData;
|
|
1948
|
+
if (this.subContent) {
|
|
1949
|
+
subData = new Uint8Array(this.subContent);
|
|
1950
|
+
this.emitLoadProgress('vobsub', this.memoryProgress(subData.byteLength), this.state.timestamps.length);
|
|
1951
|
+
}
|
|
1952
|
+
else {
|
|
1953
|
+
if (!this.subUrl) {
|
|
1954
|
+
throw createSubtitleDiagnosticError('MISSING_INPUT', 'No SUB content or URL provided.', { format: 'vobsub' });
|
|
1955
|
+
}
|
|
1956
|
+
const fetched = await fetchSubtitleAsset(this.subUrl, {
|
|
1957
|
+
preferRange: this.rangeRequests && this.streamingLoad,
|
|
1958
|
+
onProgress: (progress) => this.emitLoadProgress('vobsub', progress, this.state.timestamps.length)
|
|
1959
|
+
});
|
|
1960
|
+
subData = fetched.data;
|
|
1961
|
+
this.emitLoadProgress('vobsub', {
|
|
1962
|
+
loaded: subData.byteLength,
|
|
1963
|
+
total: fetched.total ?? subData.byteLength,
|
|
1964
|
+
ratio: 1,
|
|
1965
|
+
rangeSupported: fetched.rangeSupported,
|
|
1966
|
+
strategy: fetched.strategy
|
|
1967
|
+
}, this.state.timestamps.length);
|
|
1968
|
+
}
|
|
1969
|
+
if (usedWorker && this.state.sessionId) {
|
|
1970
|
+
const transferable = createTransferableBuffer(subData, Boolean(this.subContent));
|
|
1971
|
+
const attachResponse = await sendToWorker({
|
|
1972
|
+
type: 'attachVobSubData',
|
|
1973
|
+
sessionId: this.state.sessionId,
|
|
1974
|
+
subData: transferable
|
|
1975
|
+
});
|
|
1976
|
+
if (attachResponse.type === 'vobSubProgress') {
|
|
1977
|
+
this.applyVobSubIndexState(attachResponse.metadata, attachResponse.timestamps, false, true, true);
|
|
1978
|
+
this.state.workerReady = true;
|
|
1979
|
+
this.isLoaded = true;
|
|
1980
|
+
this.emitWorkerState(true, true, this.state.sessionId);
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
else if (attachResponse.type === 'error') {
|
|
1984
|
+
throw new Error(attachResponse.message);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
if (!this.vobsubParser) {
|
|
1988
|
+
this.vobsubParser = new VobSubParserLowLevel({
|
|
1989
|
+
debug: this.debug,
|
|
1990
|
+
onWarning: (warning) => this.emitWarning(warning)
|
|
1991
|
+
});
|
|
1992
|
+
this.vobsubParser.loadFromIdx(idxData);
|
|
1092
1993
|
}
|
|
1994
|
+
this.vobsubParser.attachSubData(subData);
|
|
1995
|
+
const metadata = this.vobsubParser.getMetadata();
|
|
1996
|
+
this.state.timestamps = this.vobsubParser.getTimestamps();
|
|
1997
|
+
this.state.metadata = metadata;
|
|
1998
|
+
this.isLoaded = true;
|
|
1999
|
+
this.setParserMetadata(metadata);
|
|
2000
|
+
this.emitIndexed('vobsub', metadata, false);
|
|
1093
2001
|
}
|
|
1094
2002
|
async loadOnMainThread(subData, idxData, useMksSource = false) {
|
|
1095
|
-
// Yield to browser before heavy parsing
|
|
1096
2003
|
await this.yieldToMain();
|
|
1097
|
-
this.vobsubParser = new VobSubParserLowLevel({
|
|
1098
|
-
|
|
2004
|
+
this.vobsubParser = new VobSubParserLowLevel({
|
|
2005
|
+
debug: this.debug,
|
|
2006
|
+
onWarning: (warning) => this.emitWarning(warning)
|
|
2007
|
+
});
|
|
1099
2008
|
await new Promise((resolve) => {
|
|
1100
2009
|
const scheduleTask = typeof requestIdleCallback !== 'undefined'
|
|
1101
2010
|
? (cb) => requestIdleCallback(() => cb(), { timeout: 1000 })
|
|
@@ -1114,6 +2023,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1114
2023
|
this.state.metadata = this.vobsubParser.getMetadata();
|
|
1115
2024
|
this.isLoaded = true;
|
|
1116
2025
|
this.setParserMetadata(this.state.metadata);
|
|
2026
|
+
this.emitIndexed('vobsub', this.state.metadata, false);
|
|
1117
2027
|
resolve();
|
|
1118
2028
|
});
|
|
1119
2029
|
});
|
|
@@ -1144,11 +2054,14 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1144
2054
|
}
|
|
1145
2055
|
// Start async lookup if not already pending
|
|
1146
2056
|
if (!this.pendingIndexLookup) {
|
|
1147
|
-
|
|
2057
|
+
const presentationToken = this.getPresentationToken();
|
|
2058
|
+
const lookup = sendToWorker({
|
|
1148
2059
|
type: 'findVobSubIndex',
|
|
1149
2060
|
sessionId: this.state.sessionId,
|
|
1150
2061
|
timeMs
|
|
1151
2062
|
}).then((response) => {
|
|
2063
|
+
if (!this.isCurrentPresentation(presentationToken))
|
|
2064
|
+
return this.cachedIndex;
|
|
1152
2065
|
if (response.type === 'vobSubIndex') {
|
|
1153
2066
|
const newIndex = response.index;
|
|
1154
2067
|
const oldIndex = this.cachedIndex;
|
|
@@ -1159,9 +2072,18 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1159
2072
|
this.lastRenderedIndex = -2; // Use -2 to force update even when new index is -1
|
|
1160
2073
|
}
|
|
1161
2074
|
}
|
|
1162
|
-
this.pendingIndexLookup = null;
|
|
1163
2075
|
return this.cachedIndex;
|
|
1164
2076
|
});
|
|
2077
|
+
this.pendingIndexLookup = lookup;
|
|
2078
|
+
void lookup.then(() => {
|
|
2079
|
+
if (this.pendingIndexLookup !== lookup)
|
|
2080
|
+
return;
|
|
2081
|
+
this.pendingIndexLookup = null;
|
|
2082
|
+
this.renderPausedFrame();
|
|
2083
|
+
}, () => {
|
|
2084
|
+
if (this.pendingIndexLookup === lookup)
|
|
2085
|
+
this.pendingIndexLookup = null;
|
|
2086
|
+
});
|
|
1165
2087
|
}
|
|
1166
2088
|
return this.cachedIndex;
|
|
1167
2089
|
}
|
|
@@ -1194,12 +2116,9 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1194
2116
|
setCachedFrame(this.state, index, frame, renderIssue);
|
|
1195
2117
|
this.state.pendingRenders.delete(index);
|
|
1196
2118
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
1197
|
-
// Force re-render on next frame by resetting lastRenderedIndex
|
|
1198
|
-
if (this.findCurrentIndex(this.video.currentTime + this.timeOffset) === index) {
|
|
1199
|
-
this.lastRenderedIndex = -1;
|
|
1200
|
-
}
|
|
1201
2119
|
});
|
|
1202
2120
|
}
|
|
2121
|
+
this.watchPendingRender(index, this.state.pendingRenders.get(index));
|
|
1203
2122
|
// Return undefined to indicate async loading in progress
|
|
1204
2123
|
return undefined;
|
|
1205
2124
|
}
|
|
@@ -1218,6 +2137,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1218
2137
|
const startTime = this.state.timestamps[index];
|
|
1219
2138
|
const endTime = this.state.timestamps[index + 1] ?? startTime + 5000;
|
|
1220
2139
|
const frame = this.state.frameCache.get(index) ?? null;
|
|
2140
|
+
const offscreenFrame = this.getOffscreenFrameMetadata(index);
|
|
1221
2141
|
return {
|
|
1222
2142
|
index,
|
|
1223
2143
|
format: 'vobsub',
|
|
@@ -1226,8 +2146,8 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1226
2146
|
duration: Math.max(0, endTime - startTime),
|
|
1227
2147
|
screenWidth: metadata.screenWidth,
|
|
1228
2148
|
screenHeight: metadata.screenHeight,
|
|
1229
|
-
bounds: frame ? getSubtitleBounds(frame) : null,
|
|
1230
|
-
compositionCount: frame?.compositionData.length ?? 0,
|
|
2149
|
+
bounds: frame ? getSubtitleBounds(frame) : (offscreenFrame?.bounds ?? null),
|
|
2150
|
+
compositionCount: frame?.compositionData.length ?? offscreenFrame?.compositionCount ?? 0,
|
|
1231
2151
|
language: metadata.language ?? null,
|
|
1232
2152
|
trackId: metadata.trackId ?? null
|
|
1233
2153
|
};
|
|
@@ -1254,9 +2174,11 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1254
2174
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
1255
2175
|
}
|
|
1256
2176
|
clearFrameCache() {
|
|
2177
|
+
this.invalidatePresentation();
|
|
1257
2178
|
this.state.frameCache.clear();
|
|
1258
2179
|
this.state.renderIssues.clear();
|
|
1259
2180
|
this.state.pendingRenders.clear();
|
|
2181
|
+
this.clearOffscreenFrameMetadata();
|
|
1260
2182
|
this.cachedIndex = -1;
|
|
1261
2183
|
this.cachedIndexTime = -1;
|
|
1262
2184
|
this.pendingIndexLookup = null;
|
|
@@ -1266,6 +2188,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1266
2188
|
}
|
|
1267
2189
|
this.vobsubParser?.clearCache();
|
|
1268
2190
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
2191
|
+
this.renderPausedFrame();
|
|
1269
2192
|
}
|
|
1270
2193
|
async prefetchRange(startIndex, endIndex) {
|
|
1271
2194
|
const safeStart = Math.max(0, Math.min(startIndex, endIndex));
|
|
@@ -1302,10 +2225,13 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1302
2225
|
sendToWorker({ type: 'setVobSubDebandEnabled', sessionId: this.state.sessionId, enabled }).catch(() => { });
|
|
1303
2226
|
}
|
|
1304
2227
|
this.vobsubParser?.setDebandEnabled(enabled);
|
|
2228
|
+
this.invalidatePresentation();
|
|
1305
2229
|
// Clear cache to force re-render with new settings
|
|
1306
2230
|
this.state.frameCache.clear();
|
|
2231
|
+
this.clearOffscreenFrameMetadata();
|
|
1307
2232
|
this.lastRenderedIndex = -1;
|
|
1308
2233
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
2234
|
+
this.renderPausedFrame();
|
|
1309
2235
|
}
|
|
1310
2236
|
/** Set debanding threshold (0-255, default: 64) */
|
|
1311
2237
|
setDebandThreshold(threshold) {
|
|
@@ -1313,10 +2239,13 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1313
2239
|
sendToWorker({ type: 'setVobSubDebandThreshold', sessionId: this.state.sessionId, threshold }).catch(() => { });
|
|
1314
2240
|
}
|
|
1315
2241
|
this.vobsubParser?.setDebandThreshold(threshold);
|
|
2242
|
+
this.invalidatePresentation();
|
|
1316
2243
|
// Clear cache to force re-render with new settings
|
|
1317
2244
|
this.state.frameCache.clear();
|
|
2245
|
+
this.clearOffscreenFrameMetadata();
|
|
1318
2246
|
this.lastRenderedIndex = -1;
|
|
1319
2247
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
2248
|
+
this.renderPausedFrame();
|
|
1320
2249
|
}
|
|
1321
2250
|
/** Set debanding sample range in pixels (1-64, default: 15) */
|
|
1322
2251
|
setDebandRange(range) {
|
|
@@ -1324,10 +2253,13 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1324
2253
|
sendToWorker({ type: 'setVobSubDebandRange', sessionId: this.state.sessionId, range }).catch(() => { });
|
|
1325
2254
|
}
|
|
1326
2255
|
this.vobsubParser?.setDebandRange(range);
|
|
2256
|
+
this.invalidatePresentation();
|
|
1327
2257
|
// Clear cache to force re-render with new settings
|
|
1328
2258
|
this.state.frameCache.clear();
|
|
2259
|
+
this.clearOffscreenFrameMetadata();
|
|
1329
2260
|
this.lastRenderedIndex = -1;
|
|
1330
2261
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
2262
|
+
this.renderPausedFrame();
|
|
1331
2263
|
}
|
|
1332
2264
|
/** Check if debanding is enabled */
|
|
1333
2265
|
get debandEnabled() {
|
|
@@ -1346,6 +2278,557 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1346
2278
|
this.state.sessionId = null;
|
|
1347
2279
|
}
|
|
1348
2280
|
}
|
|
2281
|
+
export class DvbRenderer extends BaseVideoSubtitleRenderer {
|
|
2282
|
+
dvbParser = null;
|
|
2283
|
+
endTimestamps = new Float64Array(0);
|
|
2284
|
+
state = createWorkerState();
|
|
2285
|
+
streamOperationQueue = Promise.resolve();
|
|
2286
|
+
streamGeneration = 0;
|
|
2287
|
+
onLoading;
|
|
2288
|
+
onLoaded;
|
|
2289
|
+
onError;
|
|
2290
|
+
constructor(options) {
|
|
2291
|
+
super(options, 'dvb');
|
|
2292
|
+
this.onLoading = options.onLoading;
|
|
2293
|
+
this.onLoaded = options.onLoaded;
|
|
2294
|
+
this.onError = options.onError;
|
|
2295
|
+
applyCacheLimit(this.state, this.cacheLimit);
|
|
2296
|
+
this.startInit();
|
|
2297
|
+
}
|
|
2298
|
+
async loadSubtitles() {
|
|
2299
|
+
try {
|
|
2300
|
+
this.emitEvent({ type: 'loading', format: 'dvb' });
|
|
2301
|
+
this.onLoading?.();
|
|
2302
|
+
if (this.subContent) {
|
|
2303
|
+
const data = new Uint8Array(this.subContent);
|
|
2304
|
+
this.emitLoadProgress('dvb', this.memoryProgress(data.byteLength), 0);
|
|
2305
|
+
await this.loadDvbBuffer(data, true);
|
|
2306
|
+
this.onLoaded?.();
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
if (!this.subUrl) {
|
|
2310
|
+
await this.beginDvbPushSession();
|
|
2311
|
+
this.onLoaded?.();
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
if (!this.streamingLoad) {
|
|
2315
|
+
const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(this.subUrl, {
|
|
2316
|
+
preferRange: this.rangeRequests,
|
|
2317
|
+
onProgress: (progress) => this.emitLoadProgress('dvb', progress, this.state.timestamps.length)
|
|
2318
|
+
});
|
|
2319
|
+
this.emitLoadProgress('dvb', { loaded: data.byteLength, total: total ?? data.byteLength, ratio: 1, rangeSupported, strategy }, 0);
|
|
2320
|
+
await this.loadDvbBuffer(data, false);
|
|
2321
|
+
this.onLoaded?.();
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
await this.loadDvbStreaming(this.subUrl);
|
|
2325
|
+
this.onLoaded?.();
|
|
2326
|
+
}
|
|
2327
|
+
catch (error) {
|
|
2328
|
+
const resolvedError = normalizeSubtitleError(error, { format: 'dvb' });
|
|
2329
|
+
this.emitEvent({ type: 'error', format: 'dvb', error: resolvedError });
|
|
2330
|
+
this.onError?.(resolvedError);
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
applyDvbIndexState(metadata, timestamps, endTimestamps, partial, usingWorker) {
|
|
2334
|
+
this.state.metadata = metadata;
|
|
2335
|
+
this.state.timestamps = timestamps;
|
|
2336
|
+
this.endTimestamps = endTimestamps;
|
|
2337
|
+
this.setParserMetadata(metadata);
|
|
2338
|
+
this.emitIndexed('dvb', metadata, partial);
|
|
2339
|
+
if (!this.isLoaded && timestamps.length > 0) {
|
|
2340
|
+
this.isLoaded = true;
|
|
2341
|
+
if (usingWorker) {
|
|
2342
|
+
this.state.workerReady = true;
|
|
2343
|
+
this.emitWorkerState(true, true, this.state.sessionId);
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
async beginDvbPushSession() {
|
|
2348
|
+
if (this.state.useWorker) {
|
|
2349
|
+
try {
|
|
2350
|
+
this.state.sessionId = createWorkerSessionId();
|
|
2351
|
+
await getOrCreateWorker();
|
|
2352
|
+
this.emitWorkerState(true, false, this.state.sessionId);
|
|
2353
|
+
const response = await sendToWorker({ type: 'beginDvb', sessionId: this.state.sessionId });
|
|
2354
|
+
if (response.type === 'error')
|
|
2355
|
+
throw new Error(response.message);
|
|
2356
|
+
if (response.type !== 'dvbProgress')
|
|
2357
|
+
throw new Error('Unexpected DVB worker response');
|
|
2358
|
+
this.applyDvbIndexState(response.metadata, response.timestamps, response.endTimestamps, true, true);
|
|
2359
|
+
this.state.workerReady = true;
|
|
2360
|
+
this.isLoaded = true;
|
|
2361
|
+
this.emitWorkerState(true, true, this.state.sessionId);
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
catch (workerError) {
|
|
2365
|
+
this.state.useWorker = false;
|
|
2366
|
+
this.state.workerReady = false;
|
|
2367
|
+
this.state.sessionId = null;
|
|
2368
|
+
this.emitWorkerState(false, false, null, true);
|
|
2369
|
+
this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'DVB worker initialization failed, falling back to main-thread rendering.', {
|
|
2370
|
+
format: 'dvb',
|
|
2371
|
+
details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
|
|
2372
|
+
}));
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
await this.yieldToMain();
|
|
2376
|
+
this.dvbParser = new DvbParser({ debug: this.debug, onWarning: (warning) => this.emitWarning(warning) });
|
|
2377
|
+
this.dvbParser.reset();
|
|
2378
|
+
const metadata = this.dvbParser.getMetadata();
|
|
2379
|
+
this.applyDvbIndexState(metadata, this.dvbParser.getTimestamps(), this.dvbParser.getEndTimestamps(), true, false);
|
|
2380
|
+
this.isLoaded = true;
|
|
2381
|
+
}
|
|
2382
|
+
enqueueStreamOperation(operation) {
|
|
2383
|
+
const run = async () => {
|
|
2384
|
+
try {
|
|
2385
|
+
await this.waitUntilInitialized();
|
|
2386
|
+
return await operation();
|
|
2387
|
+
}
|
|
2388
|
+
catch (error) {
|
|
2389
|
+
const resolvedError = normalizeSubtitleError(error, { format: 'dvb' });
|
|
2390
|
+
this.emitEvent({ type: 'error', format: 'dvb', error: resolvedError });
|
|
2391
|
+
this.onError?.(resolvedError);
|
|
2392
|
+
throw resolvedError;
|
|
2393
|
+
}
|
|
2394
|
+
};
|
|
2395
|
+
const result = this.streamOperationQueue.then(run, run);
|
|
2396
|
+
this.streamOperationQueue = result.then(() => undefined, () => undefined);
|
|
2397
|
+
return result;
|
|
2398
|
+
}
|
|
2399
|
+
/** Append a chunk to the active DVB stream and return the number of newly indexed cues. */
|
|
2400
|
+
append(data) {
|
|
2401
|
+
return this.enqueueStreamOperation(async () => {
|
|
2402
|
+
const chunk = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
2403
|
+
if (chunk.byteLength === 0)
|
|
2404
|
+
return 0;
|
|
2405
|
+
let added;
|
|
2406
|
+
if (this.state.useWorker && this.state.workerReady && this.state.sessionId) {
|
|
2407
|
+
const response = await sendToWorker({
|
|
2408
|
+
type: 'appendDvb',
|
|
2409
|
+
sessionId: this.state.sessionId,
|
|
2410
|
+
data: createTransferableBuffer(chunk, true)
|
|
2411
|
+
});
|
|
2412
|
+
if (response.type === 'error')
|
|
2413
|
+
throw new Error(response.message);
|
|
2414
|
+
if (response.type !== 'dvbProgress')
|
|
2415
|
+
throw new Error('Unexpected DVB worker response');
|
|
2416
|
+
added = response.added;
|
|
2417
|
+
if (added > 0) {
|
|
2418
|
+
this.applyDvbIndexState(response.metadata, response.timestamps, response.endTimestamps, true, true);
|
|
2419
|
+
}
|
|
2420
|
+
else {
|
|
2421
|
+
this.state.metadata = response.metadata;
|
|
2422
|
+
this.state.timestamps = response.timestamps;
|
|
2423
|
+
this.endTimestamps = response.endTimestamps;
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
else {
|
|
2427
|
+
if (!this.dvbParser)
|
|
2428
|
+
throw new Error('DVB parser is not initialized');
|
|
2429
|
+
added = this.dvbParser.feed(chunk);
|
|
2430
|
+
if (added > 0) {
|
|
2431
|
+
this.applyDvbIndexState(this.dvbParser.getMetadata(), this.dvbParser.getTimestamps(), this.dvbParser.getEndTimestamps(), true, false);
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
if (added > 0)
|
|
2435
|
+
this.refreshStreamPresentation();
|
|
2436
|
+
return added;
|
|
2437
|
+
});
|
|
2438
|
+
}
|
|
2439
|
+
/** Flush incomplete trailing DVB input and return the total indexed cue count. */
|
|
2440
|
+
flush() {
|
|
2441
|
+
return this.enqueueStreamOperation(async () => {
|
|
2442
|
+
let count;
|
|
2443
|
+
if (this.state.useWorker && this.state.workerReady && this.state.sessionId) {
|
|
2444
|
+
const response = await sendToWorker({ type: 'finishDvb', sessionId: this.state.sessionId });
|
|
2445
|
+
if (response.type === 'error')
|
|
2446
|
+
throw new Error(response.message);
|
|
2447
|
+
if (response.type !== 'dvbProgress')
|
|
2448
|
+
throw new Error('Unexpected DVB worker response');
|
|
2449
|
+
count = response.count;
|
|
2450
|
+
this.applyDvbIndexState(response.metadata, response.timestamps, response.endTimestamps, false, true);
|
|
2451
|
+
}
|
|
2452
|
+
else {
|
|
2453
|
+
if (!this.dvbParser)
|
|
2454
|
+
throw new Error('DVB parser is not initialized');
|
|
2455
|
+
count = this.dvbParser.finishFeed();
|
|
2456
|
+
this.applyDvbIndexState(this.dvbParser.getMetadata(), this.dvbParser.getTimestamps(), this.dvbParser.getEndTimestamps(), false, false);
|
|
2457
|
+
}
|
|
2458
|
+
this.refreshStreamPresentation();
|
|
2459
|
+
return count;
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
/** Reset the DVB stream while keeping the renderer and canvas ready for more chunks. */
|
|
2463
|
+
reset() {
|
|
2464
|
+
return this.enqueueStreamOperation(async () => {
|
|
2465
|
+
this.streamGeneration++;
|
|
2466
|
+
try {
|
|
2467
|
+
if (this.state.useWorker && this.state.workerReady && this.state.sessionId) {
|
|
2468
|
+
const response = await sendToWorker({ type: 'resetDvb', sessionId: this.state.sessionId });
|
|
2469
|
+
if (response.type === 'error')
|
|
2470
|
+
throw new Error(response.message);
|
|
2471
|
+
if (response.type !== 'dvbProgress')
|
|
2472
|
+
throw new Error('Unexpected DVB worker response');
|
|
2473
|
+
this.applyDvbIndexState(response.metadata, response.timestamps, response.endTimestamps, true, true);
|
|
2474
|
+
}
|
|
2475
|
+
else {
|
|
2476
|
+
if (!this.dvbParser)
|
|
2477
|
+
throw new Error('DVB parser is not initialized');
|
|
2478
|
+
this.dvbParser.reset();
|
|
2479
|
+
this.applyDvbIndexState(this.dvbParser.getMetadata(), this.dvbParser.getTimestamps(), this.dvbParser.getEndTimestamps(), true, false);
|
|
2480
|
+
}
|
|
2481
|
+
this.state.frameCache.clear();
|
|
2482
|
+
this.state.renderIssues.clear();
|
|
2483
|
+
this.state.pendingRenders.clear();
|
|
2484
|
+
this.clearStreamPresentation();
|
|
2485
|
+
this.emitCacheChange(0, 0);
|
|
2486
|
+
}
|
|
2487
|
+
finally {
|
|
2488
|
+
this.streamGeneration++;
|
|
2489
|
+
}
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
async loadDvbBuffer(data, preserveSource) {
|
|
2493
|
+
if (this.state.useWorker) {
|
|
2494
|
+
try {
|
|
2495
|
+
this.state.sessionId = createWorkerSessionId();
|
|
2496
|
+
await getOrCreateWorker();
|
|
2497
|
+
this.emitWorkerState(true, false, this.state.sessionId);
|
|
2498
|
+
const transferableData = createTransferableBuffer(data, preserveSource);
|
|
2499
|
+
const loadResponse = await sendToWorker({
|
|
2500
|
+
type: 'loadDvb',
|
|
2501
|
+
sessionId: this.state.sessionId,
|
|
2502
|
+
data: transferableData
|
|
2503
|
+
});
|
|
2504
|
+
if (loadResponse.type === 'dvbLoaded') {
|
|
2505
|
+
this.state.workerReady = true;
|
|
2506
|
+
this.state.metadata = loadResponse.metadata;
|
|
2507
|
+
this.state.timestamps = loadResponse.timestamps;
|
|
2508
|
+
this.endTimestamps = loadResponse.endTimestamps;
|
|
2509
|
+
this.isLoaded = true;
|
|
2510
|
+
this.setParserMetadata(loadResponse.metadata);
|
|
2511
|
+
this.emitIndexed('dvb', loadResponse.metadata, false);
|
|
2512
|
+
this.emitWorkerState(true, true, this.state.sessionId);
|
|
2513
|
+
return;
|
|
2514
|
+
}
|
|
2515
|
+
else if (loadResponse.type === 'error') {
|
|
2516
|
+
throw new Error(loadResponse.message);
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
catch (workerError) {
|
|
2520
|
+
this.state.useWorker = false;
|
|
2521
|
+
this.emitWorkerState(false, false, this.state.sessionId, true);
|
|
2522
|
+
this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'DVB worker initialization failed, falling back to main-thread rendering.', {
|
|
2523
|
+
format: 'dvb',
|
|
2524
|
+
details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
|
|
2525
|
+
}));
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
await this.loadOnMainThread(data);
|
|
2529
|
+
}
|
|
2530
|
+
async loadDvbStreaming(url) {
|
|
2531
|
+
let usedWorker = false;
|
|
2532
|
+
let indexedOnce = false;
|
|
2533
|
+
if (this.state.useWorker) {
|
|
2534
|
+
try {
|
|
2535
|
+
this.state.sessionId = createWorkerSessionId();
|
|
2536
|
+
await getOrCreateWorker();
|
|
2537
|
+
this.emitWorkerState(true, false, this.state.sessionId);
|
|
2538
|
+
const begin = await sendToWorker({ type: 'beginDvb', sessionId: this.state.sessionId });
|
|
2539
|
+
if (begin.type === 'error')
|
|
2540
|
+
throw new Error(begin.message);
|
|
2541
|
+
usedWorker = true;
|
|
2542
|
+
}
|
|
2543
|
+
catch (workerError) {
|
|
2544
|
+
this.state.useWorker = false;
|
|
2545
|
+
usedWorker = false;
|
|
2546
|
+
this.emitWorkerState(false, false, this.state.sessionId, true);
|
|
2547
|
+
this.emitWarning(createSubtitleWarning('WORKER_FALLBACK', 'DVB worker initialization failed, falling back to main-thread rendering.', {
|
|
2548
|
+
format: 'dvb',
|
|
2549
|
+
details: { reason: workerError instanceof Error ? workerError.message : String(workerError) }
|
|
2550
|
+
}));
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
if (!usedWorker) {
|
|
2554
|
+
await this.yieldToMain();
|
|
2555
|
+
this.dvbParser = new DvbParser({ debug: this.debug, onWarning: (warning) => this.emitWarning(warning) });
|
|
2556
|
+
this.dvbParser.reset();
|
|
2557
|
+
}
|
|
2558
|
+
try {
|
|
2559
|
+
const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(url, {
|
|
2560
|
+
preferRange: this.rangeRequests,
|
|
2561
|
+
onProgress: (progress) => this.emitLoadProgress('dvb', progress, this.state.timestamps.length)
|
|
2562
|
+
}, async (chunk, progress) => {
|
|
2563
|
+
if (chunk.byteLength === 0)
|
|
2564
|
+
return;
|
|
2565
|
+
if (usedWorker && this.state.sessionId) {
|
|
2566
|
+
const transferable = createTransferableBuffer(chunk, true);
|
|
2567
|
+
const response = await sendToWorker({
|
|
2568
|
+
type: 'appendDvb',
|
|
2569
|
+
sessionId: this.state.sessionId,
|
|
2570
|
+
data: transferable
|
|
2571
|
+
});
|
|
2572
|
+
if (response.type === 'dvbProgress') {
|
|
2573
|
+
if (response.added > 0 || !indexedOnce) {
|
|
2574
|
+
this.applyDvbIndexState(response.metadata, response.timestamps, response.endTimestamps, true, true);
|
|
2575
|
+
indexedOnce = true;
|
|
2576
|
+
}
|
|
2577
|
+
else {
|
|
2578
|
+
this.state.timestamps = response.timestamps;
|
|
2579
|
+
this.endTimestamps = response.endTimestamps;
|
|
2580
|
+
this.state.metadata = response.metadata;
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
else if (response.type === 'error') {
|
|
2584
|
+
throw new Error(response.message);
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
else if (this.dvbParser) {
|
|
2588
|
+
const added = this.dvbParser.feed(chunk);
|
|
2589
|
+
if (added > 0 || !indexedOnce) {
|
|
2590
|
+
const metadata = this.dvbParser.getMetadata();
|
|
2591
|
+
this.applyDvbIndexState(metadata, this.dvbParser.getTimestamps(), this.dvbParser.getEndTimestamps(), true, false);
|
|
2592
|
+
indexedOnce = true;
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
this.emitLoadProgress('dvb', progress, this.state.timestamps.length);
|
|
2596
|
+
});
|
|
2597
|
+
if (usedWorker && this.state.sessionId) {
|
|
2598
|
+
const finish = await sendToWorker({ type: 'finishDvb', sessionId: this.state.sessionId });
|
|
2599
|
+
if (finish.type === 'dvbProgress') {
|
|
2600
|
+
this.applyDvbIndexState(finish.metadata, finish.timestamps, finish.endTimestamps, false, true);
|
|
2601
|
+
this.state.workerReady = true;
|
|
2602
|
+
this.isLoaded = true;
|
|
2603
|
+
this.emitWorkerState(true, true, this.state.sessionId);
|
|
2604
|
+
}
|
|
2605
|
+
else if (finish.type === 'error') {
|
|
2606
|
+
throw new Error(finish.message);
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
else if (this.dvbParser) {
|
|
2610
|
+
this.dvbParser.finishFeed();
|
|
2611
|
+
const metadata = this.dvbParser.getMetadata();
|
|
2612
|
+
this.state.timestamps = this.dvbParser.getTimestamps();
|
|
2613
|
+
this.endTimestamps = this.dvbParser.getEndTimestamps();
|
|
2614
|
+
this.state.metadata = metadata;
|
|
2615
|
+
this.isLoaded = true;
|
|
2616
|
+
this.setParserMetadata(metadata);
|
|
2617
|
+
this.emitIndexed('dvb', metadata, false);
|
|
2618
|
+
if (metadata.cueCount === 0) {
|
|
2619
|
+
this.state.renderIssues.set(-1, 'INVALID_SUBTITLE_DATA');
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
this.emitLoadProgress('dvb', {
|
|
2623
|
+
loaded: data.byteLength,
|
|
2624
|
+
total: total ?? data.byteLength,
|
|
2625
|
+
ratio: 1,
|
|
2626
|
+
rangeSupported,
|
|
2627
|
+
strategy: strategy
|
|
2628
|
+
}, this.state.timestamps.length);
|
|
2629
|
+
}
|
|
2630
|
+
catch (error) {
|
|
2631
|
+
if (usedWorker) {
|
|
2632
|
+
this.state.useWorker = false;
|
|
2633
|
+
this.emitWorkerState(false, false, this.state.sessionId, true);
|
|
2634
|
+
}
|
|
2635
|
+
this.emitWarning(createSubtitleWarning('RANGE_FALLBACK', 'Progressive DVB load failed; retrying with a full buffer fetch.', {
|
|
2636
|
+
format: 'dvb',
|
|
2637
|
+
details: { reason: error instanceof Error ? error.message : String(error) }
|
|
2638
|
+
}));
|
|
2639
|
+
const { data } = await fetchSubtitleAsset(url, { preferRange: this.rangeRequests });
|
|
2640
|
+
await this.loadDvbBuffer(data, false);
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
async loadOnMainThread(data) {
|
|
2644
|
+
await this.yieldToMain();
|
|
2645
|
+
this.dvbParser = new DvbParser({ debug: this.debug, onWarning: (warning) => this.emitWarning(warning) });
|
|
2646
|
+
await new Promise((resolve) => {
|
|
2647
|
+
const scheduleTask = typeof requestIdleCallback !== 'undefined'
|
|
2648
|
+
? (cb) => requestIdleCallback(() => cb(), { timeout: 1000 })
|
|
2649
|
+
: (cb) => setTimeout(cb, 0);
|
|
2650
|
+
scheduleTask(() => {
|
|
2651
|
+
const count = this.dvbParser.load(data);
|
|
2652
|
+
this.state.timestamps = this.dvbParser.getTimestamps();
|
|
2653
|
+
this.endTimestamps = this.dvbParser.getEndTimestamps();
|
|
2654
|
+
this.state.metadata = this.dvbParser.getMetadata();
|
|
2655
|
+
this.isLoaded = true;
|
|
2656
|
+
this.setParserMetadata(this.state.metadata);
|
|
2657
|
+
this.emitIndexed('dvb', this.state.metadata, false);
|
|
2658
|
+
if (count === 0) {
|
|
2659
|
+
this.state.renderIssues.set(-1, 'INVALID_SUBTITLE_DATA');
|
|
2660
|
+
}
|
|
2661
|
+
resolve();
|
|
2662
|
+
});
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
getWorkerRendererState() {
|
|
2666
|
+
return this.state;
|
|
2667
|
+
}
|
|
2668
|
+
/** Yield to main thread to prevent UI blocking */
|
|
2669
|
+
yieldToMain() {
|
|
2670
|
+
// Use scheduler.yield if available (Chrome 115+)
|
|
2671
|
+
const globalScheduler = globalThis.scheduler;
|
|
2672
|
+
if (globalScheduler && typeof globalScheduler.yield === 'function') {
|
|
2673
|
+
return globalScheduler.yield();
|
|
2674
|
+
}
|
|
2675
|
+
// Fallback to setTimeout
|
|
2676
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
2677
|
+
}
|
|
2678
|
+
renderAtTime(time) {
|
|
2679
|
+
const index = this.findCurrentIndex(time);
|
|
2680
|
+
return index < 0 ? undefined : this.renderAtIndex(index);
|
|
2681
|
+
}
|
|
2682
|
+
findCurrentIndex(time) {
|
|
2683
|
+
if (this.state.useWorker && this.state.workerReady) {
|
|
2684
|
+
const timeMs = time * 1000;
|
|
2685
|
+
const index = binarySearchTimestamp(this.state.timestamps, timeMs);
|
|
2686
|
+
return index >= 0 && timeMs < (this.endTimestamps[index] ?? this.state.timestamps[index]) ? index : -1;
|
|
2687
|
+
}
|
|
2688
|
+
return this.dvbParser?.findIndexAtTimestamp(time) ?? -1;
|
|
2689
|
+
}
|
|
2690
|
+
renderAtIndex(index) {
|
|
2691
|
+
if (this.state.frameCache.has(index)) {
|
|
2692
|
+
return this.state.frameCache.get(index) ?? undefined;
|
|
2693
|
+
}
|
|
2694
|
+
if (this.state.useWorker && this.state.workerReady) {
|
|
2695
|
+
if (!this.state.pendingRenders.has(index)) {
|
|
2696
|
+
const generation = this.streamGeneration;
|
|
2697
|
+
const renderTask = sendToWorker({
|
|
2698
|
+
type: 'renderDvbAtIndex',
|
|
2699
|
+
sessionId: this.state.sessionId,
|
|
2700
|
+
index
|
|
2701
|
+
}).then((response) => {
|
|
2702
|
+
if (response.type === 'dvbFrame') {
|
|
2703
|
+
return {
|
|
2704
|
+
frame: response.frame ? convertFrameData(response.frame) : null,
|
|
2705
|
+
renderIssue: response.renderIssue?.trim() || null
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
return { frame: null, renderIssue: null };
|
|
2709
|
+
});
|
|
2710
|
+
const renderPromise = renderTask.then(({ frame }) => frame);
|
|
2711
|
+
this.state.pendingRenders.set(index, renderPromise);
|
|
2712
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
2713
|
+
renderTask.then(({ frame, renderIssue }) => {
|
|
2714
|
+
if (generation !== this.streamGeneration || this.disposed)
|
|
2715
|
+
return;
|
|
2716
|
+
setCachedFrame(this.state, index, frame, renderIssue);
|
|
2717
|
+
this.state.pendingRenders.delete(index);
|
|
2718
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2721
|
+
this.watchPendingRender(index, this.state.pendingRenders.get(index));
|
|
2722
|
+
// Return undefined to indicate async loading in progress
|
|
2723
|
+
return undefined;
|
|
2724
|
+
}
|
|
2725
|
+
const rendered = this.dvbParser?.renderAtIndex(index) ?? null;
|
|
2726
|
+
setCachedFrame(this.state, index, rendered, this.dvbParser?.getLastRenderIssue() ?? null);
|
|
2727
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
2728
|
+
return rendered ?? undefined;
|
|
2729
|
+
}
|
|
2730
|
+
buildCueMetadata(index) {
|
|
2731
|
+
if (this.dvbParser) {
|
|
2732
|
+
return this.dvbParser.getCueMetadata(index);
|
|
2733
|
+
}
|
|
2734
|
+
const metadata = this.state.metadata;
|
|
2735
|
+
if (!metadata || index < 0 || index >= this.state.timestamps.length)
|
|
2736
|
+
return null;
|
|
2737
|
+
const startTime = this.state.timestamps[index];
|
|
2738
|
+
const endTime = this.endTimestamps[index] ?? startTime;
|
|
2739
|
+
const frame = this.state.frameCache.get(index) ?? null;
|
|
2740
|
+
const offscreenFrame = this.getOffscreenFrameMetadata(index);
|
|
2741
|
+
return {
|
|
2742
|
+
index,
|
|
2743
|
+
format: 'dvb',
|
|
2744
|
+
startTime,
|
|
2745
|
+
endTime,
|
|
2746
|
+
duration: Math.max(0, endTime - startTime),
|
|
2747
|
+
screenWidth: metadata.screenWidth,
|
|
2748
|
+
screenHeight: metadata.screenHeight,
|
|
2749
|
+
bounds: frame ? getSubtitleBounds(frame) : (offscreenFrame?.bounds ?? null),
|
|
2750
|
+
compositionCount: frame?.compositionData.length ?? offscreenFrame?.compositionCount ?? 0
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2753
|
+
isPendingRender(index) {
|
|
2754
|
+
return this.state.pendingRenders.has(index);
|
|
2755
|
+
}
|
|
2756
|
+
onSeek() {
|
|
2757
|
+
this.state.frameCache.clear();
|
|
2758
|
+
this.state.renderIssues.clear();
|
|
2759
|
+
this.state.pendingRenders.clear();
|
|
2760
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
2761
|
+
if (this.state.useWorker && this.state.workerReady) {
|
|
2762
|
+
sendToWorker({ type: 'clearDvbCache', sessionId: this.state.sessionId }).catch(() => { });
|
|
2763
|
+
}
|
|
2764
|
+
this.dvbParser?.clearCache();
|
|
2765
|
+
}
|
|
2766
|
+
setCacheLimit(limit) {
|
|
2767
|
+
this.cacheLimit = applyCacheLimit(this.state, limit);
|
|
2768
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
2769
|
+
}
|
|
2770
|
+
clearFrameCache() {
|
|
2771
|
+
this.invalidatePresentation();
|
|
2772
|
+
this.state.frameCache.clear();
|
|
2773
|
+
this.state.renderIssues.clear();
|
|
2774
|
+
this.state.pendingRenders.clear();
|
|
2775
|
+
this.clearOffscreenFrameMetadata();
|
|
2776
|
+
this.lastRenderedIndex = -1;
|
|
2777
|
+
if (this.state.useWorker && this.state.workerReady) {
|
|
2778
|
+
sendToWorker({ type: 'clearDvbCache', sessionId: this.state.sessionId }).catch(() => { });
|
|
2779
|
+
}
|
|
2780
|
+
this.dvbParser?.clearCache();
|
|
2781
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
2782
|
+
this.renderPausedFrame();
|
|
2783
|
+
}
|
|
2784
|
+
async prefetchRange(startIndex, endIndex) {
|
|
2785
|
+
const safeStart = Math.max(0, Math.min(startIndex, endIndex));
|
|
2786
|
+
const safeEnd = Math.min(Math.max(startIndex, endIndex), this.state.timestamps.length - 1);
|
|
2787
|
+
for (let index = safeStart; index <= safeEnd; index++) {
|
|
2788
|
+
if (this.state.frameCache.has(index))
|
|
2789
|
+
continue;
|
|
2790
|
+
const result = this.renderAtIndex(index);
|
|
2791
|
+
if (result === undefined && this.state.pendingRenders.has(index)) {
|
|
2792
|
+
await this.state.pendingRenders.get(index);
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
async prefetchAroundTime(time, before = this.prefetchBefore, after = this.prefetchAfter) {
|
|
2797
|
+
const currentIndex = this.findCurrentIndex(time);
|
|
2798
|
+
if (currentIndex < 0)
|
|
2799
|
+
return;
|
|
2800
|
+
await this.prefetchRange(currentIndex - before, currentIndex + after);
|
|
2801
|
+
}
|
|
2802
|
+
/** Get performance statistics for DVB renderer */
|
|
2803
|
+
getStats() {
|
|
2804
|
+
const baseStats = this.getBaseStats();
|
|
2805
|
+
return {
|
|
2806
|
+
...baseStats,
|
|
2807
|
+
usingWorker: this.state.useWorker && this.state.workerReady,
|
|
2808
|
+
cachedFrames: this.state.frameCache.size,
|
|
2809
|
+
pendingRenders: this.state.pendingRenders.size,
|
|
2810
|
+
totalEntries: this.state.timestamps.length || (this.dvbParser?.getTimestamps().length ?? 0)
|
|
2811
|
+
};
|
|
2812
|
+
}
|
|
2813
|
+
dispose() {
|
|
2814
|
+
this.streamGeneration++;
|
|
2815
|
+
super.dispose();
|
|
2816
|
+
this.state.frameCache.clear();
|
|
2817
|
+
this.state.renderIssues.clear();
|
|
2818
|
+
this.state.pendingRenders.clear();
|
|
2819
|
+
if (this.state.useWorker && this.state.workerReady) {
|
|
2820
|
+
sendToWorker({ type: 'disposeDvb', sessionId: this.state.sessionId }).catch(() => { });
|
|
2821
|
+
}
|
|
2822
|
+
this.dvbParser?.dispose();
|
|
2823
|
+
this.dvbParser = null;
|
|
2824
|
+
this.endTimestamps = new Float64Array(0);
|
|
2825
|
+
this.state.sessionId = null;
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
/**
|
|
2829
|
+
* High-level VobSub subtitle renderer with Web Worker support.
|
|
2830
|
+
* Compatible with the old libpgs-js API.
|
|
2831
|
+
*/
|
|
1349
2832
|
/** Create a video subtitle renderer with automatic format detection. */
|
|
1350
2833
|
export function createAutoSubtitleRenderer(options) {
|
|
1351
2834
|
const format = detectSubtitleFormat({
|
|
@@ -1358,6 +2841,9 @@ export function createAutoSubtitleRenderer(options) {
|
|
|
1358
2841
|
if (format === 'pgs') {
|
|
1359
2842
|
return new PgsRenderer(options);
|
|
1360
2843
|
}
|
|
2844
|
+
if (format === 'dvb') {
|
|
2845
|
+
return new DvbRenderer(options);
|
|
2846
|
+
}
|
|
1361
2847
|
if (format === 'vobsub') {
|
|
1362
2848
|
return new VobSubRenderer(options);
|
|
1363
2849
|
}
|