libbitsub 1.11.0 → 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 +113 -16
- 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/react.d.ts.map +1 -1
- package/dist/integrations/react.js +7 -1
- package/dist/integrations/react.js.map +1 -1
- package/dist/integrations/shared.d.ts +2 -2
- package/dist/integrations/shared.d.ts.map +1 -1
- package/dist/integrations/shared.js.map +1 -1
- 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/parsers.d.ts +39 -2
- package/dist/ts/parsers.d.ts.map +1 -1
- package/dist/ts/parsers.js +229 -1
- package/dist/ts/parsers.js.map +1 -1
- package/dist/ts/renderers.d.ts +128 -10
- package/dist/ts/renderers.d.ts.map +1 -1
- package/dist/ts/renderers.js +1341 -138
- package/dist/ts/renderers.js.map +1 -1
- package/dist/ts/types.d.ts +159 -6
- 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/worker.d.ts.map +1 -1
- package/dist/ts/worker.js +394 -0
- package/dist/ts/worker.js.map +1 -1
- package/dist/wrapper.d.ts +4 -3
- package/dist/wrapper.d.ts.map +1 -1
- package/dist/wrapper.js +3 -2
- package/dist/wrapper.js.map +1 -1
- package/package.json +3 -2
- package/pkg/README.md +113 -16
- package/pkg/libbitsub.d.ts +51 -1
- package/pkg/libbitsub.js +178 -3
- package/pkg/libbitsub_bg.wasm +0 -0
- package/pkg/libbitsub_bg.wasm.d.ts +21 -0
- package/pkg/package.json +4 -4
- package/src/integrations/react.tsx +7 -1
- package/src/integrations/shared.ts +2 -1
- package/src/wrapper.ts +22 -2
package/dist/ts/renderers.js
CHANGED
|
@@ -6,10 +6,12 @@ import { SubtitleDiagnosticError, createSubtitleDiagnosticError, createSubtitleW
|
|
|
6
6
|
import { fetchSubtitleAsset, fetchSubtitleText } from './range-loader';
|
|
7
7
|
import { initWasm } from './wasm';
|
|
8
8
|
import { getOrCreateWorker, sendToWorker } from './worker';
|
|
9
|
+
import { canUseWorkerOffscreenRender } from './capabilities';
|
|
9
10
|
import { binarySearchTimestamp, convertFrameData, createWorkerSessionId, createWorkerState, detectSubtitleFormat, getSubtitleBounds, isMksSource, setCacheLimit as applyCacheLimit, setCachedFrame } from './utils';
|
|
10
|
-
import { PgsParser, VobSubParserLowLevel } from './parsers';
|
|
11
|
+
import { PgsParser, DvbParser, VobSubParserLowLevel } from './parsers';
|
|
11
12
|
import { WebGPURenderer, isWebGPUSupported } from './webgpu-renderer';
|
|
12
13
|
import { WebGL2Renderer, isWebGL2Supported } from './webgl2-renderer';
|
|
14
|
+
import { VideoFrameScheduler, supportsFrameAwareSync } from './video-frame-scheduler';
|
|
13
15
|
/** Default display settings */
|
|
14
16
|
const DEFAULT_DISPLAY_SETTINGS = {
|
|
15
17
|
scale: 1.0,
|
|
@@ -40,8 +42,14 @@ class BaseVideoSubtitleRenderer {
|
|
|
40
42
|
subUrl;
|
|
41
43
|
subContent;
|
|
42
44
|
canvas = null;
|
|
45
|
+
providedCanvas;
|
|
46
|
+
requestedContainer;
|
|
47
|
+
overlayContainer = null;
|
|
48
|
+
ownsCanvas = false;
|
|
49
|
+
devicePixelRatioCap;
|
|
50
|
+
backendOption;
|
|
43
51
|
ctx = null;
|
|
44
|
-
|
|
52
|
+
frameScheduler = null;
|
|
45
53
|
isLoaded = false;
|
|
46
54
|
lastRenderedIndex = -1;
|
|
47
55
|
lastRenderedTime = -1;
|
|
@@ -55,8 +63,7 @@ class BaseVideoSubtitleRenderer {
|
|
|
55
63
|
parserMetadata = null;
|
|
56
64
|
/** Display settings for subtitle rendering */
|
|
57
65
|
displaySettings = { ...DEFAULT_DISPLAY_SETTINGS };
|
|
58
|
-
|
|
59
|
-
timeOffset = 0;
|
|
66
|
+
_timeOffset = 0;
|
|
60
67
|
cacheLimit = 24;
|
|
61
68
|
prefetchBefore = 0;
|
|
62
69
|
prefetchAfter = 0;
|
|
@@ -69,6 +76,7 @@ class BaseVideoSubtitleRenderer {
|
|
|
69
76
|
lastRenderInfo = null;
|
|
70
77
|
loadedMetadataHandler = null;
|
|
71
78
|
seekedHandler = null;
|
|
79
|
+
initPromise = null;
|
|
72
80
|
// WebGPU renderer (optional, falls back to WebGL2 then Canvas2D)
|
|
73
81
|
webgpuRenderer = null;
|
|
74
82
|
useWebGPU = false;
|
|
@@ -77,6 +85,18 @@ class BaseVideoSubtitleRenderer {
|
|
|
77
85
|
webgl2Renderer = null;
|
|
78
86
|
useWebGL2 = false;
|
|
79
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;
|
|
80
100
|
// Performance tracking
|
|
81
101
|
perfStats = {
|
|
82
102
|
framesRendered: 0,
|
|
@@ -91,8 +111,19 @@ class BaseVideoSubtitleRenderer {
|
|
|
91
111
|
this.format = format;
|
|
92
112
|
this.subUrl = options.subUrl;
|
|
93
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';
|
|
94
123
|
this.onWebGPUFallback = options.onWebGPUFallback;
|
|
95
124
|
this.onWebGL2Fallback = options.onWebGL2Fallback;
|
|
125
|
+
this.offscreenRenderOption = options.offscreenRender !== false;
|
|
126
|
+
this.frameAwareSync = options.frameAwareSync !== false;
|
|
96
127
|
this.onEvent = options.onEvent;
|
|
97
128
|
this.onWarning = options.onWarning;
|
|
98
129
|
this.debug = Boolean(options.debug);
|
|
@@ -132,6 +163,20 @@ class BaseVideoSubtitleRenderer {
|
|
|
132
163
|
getDisplaySettings() {
|
|
133
164
|
return { ...this.displaySettings };
|
|
134
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
|
+
}
|
|
135
180
|
/** Get cache-related diagnostics for the active renderer session. */
|
|
136
181
|
getCacheStats() {
|
|
137
182
|
const state = this.getWorkerRendererState();
|
|
@@ -188,9 +233,16 @@ class BaseVideoSubtitleRenderer {
|
|
|
188
233
|
minRenderTime: Math.round(minRenderTime * 100) / 100,
|
|
189
234
|
lastRenderTime: Math.round(this.perfStats.lastRenderTime * 100) / 100,
|
|
190
235
|
renderFps: this.perfStats.fpsTimestamps.length,
|
|
191
|
-
currentIndex: this.lastRenderedIndex
|
|
236
|
+
currentIndex: this.lastRenderedIndex,
|
|
237
|
+
syncMode: this.getSynchronizationMode()
|
|
192
238
|
};
|
|
193
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
|
+
}
|
|
194
246
|
/** Set display settings and force re-render */
|
|
195
247
|
setDisplaySettings(settings) {
|
|
196
248
|
const nextSettings = {
|
|
@@ -210,19 +262,24 @@ class BaseVideoSubtitleRenderer {
|
|
|
210
262
|
this.displaySettings = nextSettings;
|
|
211
263
|
// Force re-render if settings changed
|
|
212
264
|
if (changed) {
|
|
265
|
+
this.invalidatePresentation();
|
|
213
266
|
this.lastRenderedIndex = -1;
|
|
214
267
|
this.lastRenderedTime = -1;
|
|
268
|
+
this.renderPausedFrame();
|
|
215
269
|
}
|
|
216
270
|
}
|
|
217
271
|
/** Reset display settings to defaults */
|
|
218
272
|
resetDisplaySettings() {
|
|
219
273
|
this.displaySettings = { ...DEFAULT_DISPLAY_SETTINGS };
|
|
274
|
+
this.invalidatePresentation();
|
|
220
275
|
this.lastRenderedIndex = -1;
|
|
221
276
|
this.lastRenderedTime = -1;
|
|
277
|
+
this.renderPausedFrame();
|
|
222
278
|
}
|
|
223
279
|
/** Start initialization. */
|
|
224
280
|
startInit() {
|
|
225
|
-
this.init()
|
|
281
|
+
this.initPromise = this.init();
|
|
282
|
+
this.initPromise.catch((error) => {
|
|
226
283
|
this.emitEvent({
|
|
227
284
|
type: 'error',
|
|
228
285
|
format: this.format,
|
|
@@ -230,51 +287,275 @@ class BaseVideoSubtitleRenderer {
|
|
|
230
287
|
});
|
|
231
288
|
});
|
|
232
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
|
+
}
|
|
233
330
|
/** Initialize the renderer. */
|
|
234
331
|
async init() {
|
|
235
332
|
await initWasm();
|
|
236
333
|
this.createCanvas();
|
|
237
334
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
238
335
|
await this.loadSubtitles();
|
|
336
|
+
await this.ensureWorkerOffscreenAttached();
|
|
239
337
|
this.startRenderLoop();
|
|
240
338
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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;
|
|
255
406
|
}
|
|
256
|
-
// Try WebGPU first, then WebGL2, then Canvas2D
|
|
257
407
|
if (isWebGPUSupported()) {
|
|
258
|
-
this.initWebGPU();
|
|
408
|
+
void this.initWebGPU();
|
|
259
409
|
}
|
|
260
410
|
else if (isWebGL2Supported()) {
|
|
261
|
-
this.initWebGL2();
|
|
411
|
+
void this.initWebGL2();
|
|
412
|
+
}
|
|
413
|
+
else if (this.shouldPreferWorkerOffscreen()) {
|
|
414
|
+
this.pendingWorkerOffscreen = true;
|
|
262
415
|
}
|
|
263
416
|
else {
|
|
264
417
|
this.initCanvas2D();
|
|
265
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();
|
|
266
426
|
this.updateCanvasSize();
|
|
267
427
|
this.resizeObserver = new ResizeObserver(() => this.updateCanvasSize());
|
|
268
428
|
this.resizeObserver.observe(this.video);
|
|
269
429
|
this.loadedMetadataHandler = () => this.updateCanvasSize();
|
|
270
430
|
this.seekedHandler = () => {
|
|
431
|
+
this.invalidatePresentation();
|
|
271
432
|
this.lastRenderedIndex = -1;
|
|
272
433
|
this.lastRenderedTime = -1;
|
|
434
|
+
this.lastSynchronizedTime = null;
|
|
435
|
+
this.offscreenFrameMetadata.clear();
|
|
273
436
|
this.onSeek();
|
|
437
|
+
this.renderPausedFrame();
|
|
274
438
|
};
|
|
275
439
|
this.video.addEventListener('loadedmetadata', this.loadedMetadataHandler);
|
|
276
440
|
this.video.addEventListener('seeked', this.seekedHandler);
|
|
277
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
|
+
}
|
|
278
559
|
emitEvent(event) {
|
|
279
560
|
this.onEvent?.(event);
|
|
280
561
|
}
|
|
@@ -318,15 +599,16 @@ class BaseVideoSubtitleRenderer {
|
|
|
318
599
|
this.lastRenderInfo = info;
|
|
319
600
|
}
|
|
320
601
|
/** Initialize WebGPU renderer. */
|
|
321
|
-
async initWebGPU() {
|
|
602
|
+
async initWebGPU(allowBackendFallback = true) {
|
|
322
603
|
try {
|
|
323
604
|
this.webgpuRenderer = new WebGPURenderer();
|
|
324
605
|
await this.webgpuRenderer.init();
|
|
325
606
|
if (!this.canvas)
|
|
326
607
|
return;
|
|
327
608
|
const bounds = this.getVideoContentBounds();
|
|
328
|
-
const
|
|
329
|
-
const
|
|
609
|
+
const pixelRatio = this.getDevicePixelRatio();
|
|
610
|
+
const width = Math.max(1, bounds.width * pixelRatio);
|
|
611
|
+
const height = Math.max(1, bounds.height * pixelRatio);
|
|
330
612
|
await this.webgpuRenderer.setCanvas(this.canvas, width, height);
|
|
331
613
|
this.useWebGPU = true;
|
|
332
614
|
this.emitRendererBackend('webgpu');
|
|
@@ -336,25 +618,25 @@ class BaseVideoSubtitleRenderer {
|
|
|
336
618
|
this.webgpuRenderer = null;
|
|
337
619
|
this.useWebGPU = false;
|
|
338
620
|
this.onWebGPUFallback?.();
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
this.initWebGL2();
|
|
621
|
+
if (allowBackendFallback && isWebGL2Supported()) {
|
|
622
|
+
void this.initWebGL2();
|
|
342
623
|
}
|
|
343
624
|
else {
|
|
344
|
-
this.
|
|
625
|
+
this.preferWorkerOffscreenOrCanvas2D();
|
|
345
626
|
}
|
|
346
627
|
}
|
|
347
628
|
}
|
|
348
629
|
/** Initialize WebGL2 renderer. */
|
|
349
|
-
async initWebGL2() {
|
|
630
|
+
async initWebGL2(allowBackendFallback = true) {
|
|
350
631
|
try {
|
|
351
632
|
this.webgl2Renderer = new WebGL2Renderer();
|
|
352
633
|
await this.webgl2Renderer.init();
|
|
353
634
|
if (!this.canvas)
|
|
354
635
|
return;
|
|
355
636
|
const bounds = this.getVideoContentBounds();
|
|
356
|
-
const
|
|
357
|
-
const
|
|
637
|
+
const pixelRatio = this.getDevicePixelRatio();
|
|
638
|
+
const width = Math.max(1, bounds.width * pixelRatio);
|
|
639
|
+
const height = Math.max(1, bounds.height * pixelRatio);
|
|
358
640
|
await this.webgl2Renderer.setCanvas(this.canvas, width, height);
|
|
359
641
|
this.useWebGL2 = true;
|
|
360
642
|
this.emitRendererBackend('webgl2');
|
|
@@ -364,7 +646,12 @@ class BaseVideoSubtitleRenderer {
|
|
|
364
646
|
this.webgl2Renderer = null;
|
|
365
647
|
this.useWebGL2 = false;
|
|
366
648
|
this.onWebGL2Fallback?.();
|
|
367
|
-
|
|
649
|
+
if (allowBackendFallback) {
|
|
650
|
+
this.preferWorkerOffscreenOrCanvas2D();
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
this.initCanvas2D();
|
|
654
|
+
}
|
|
368
655
|
}
|
|
369
656
|
}
|
|
370
657
|
/** Initialize Canvas2D renderer. */
|
|
@@ -378,6 +665,23 @@ class BaseVideoSubtitleRenderer {
|
|
|
378
665
|
}
|
|
379
666
|
/** Called when video seeks. */
|
|
380
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
|
+
}
|
|
381
685
|
/** Calculate the actual video content bounds, accounting for letterboxing/pillarboxing */
|
|
382
686
|
getVideoContentBounds() {
|
|
383
687
|
const rect = this.video.getBoundingClientRect();
|
|
@@ -420,96 +724,181 @@ class BaseVideoSubtitleRenderer {
|
|
|
420
724
|
const bounds = this.getVideoContentBounds();
|
|
421
725
|
const width = bounds.width > 0 ? bounds.width : this.video.videoWidth || 1920;
|
|
422
726
|
const height = bounds.height > 0 ? bounds.height : this.video.videoHeight || 1080;
|
|
423
|
-
const
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
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;
|
|
427
732
|
// Position canvas to match video content area
|
|
428
|
-
|
|
429
|
-
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`;
|
|
430
736
|
this.canvas.style.width = `${bounds.width}px`;
|
|
431
737
|
this.canvas.style.height = `${bounds.height}px`;
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
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
|
+
}
|
|
435
748
|
}
|
|
436
|
-
else
|
|
437
|
-
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
|
+
}
|
|
438
759
|
}
|
|
439
760
|
this.lastRenderedIndex = -1;
|
|
440
761
|
this.lastRenderedTime = -1;
|
|
762
|
+
this.invalidatePresentation();
|
|
763
|
+
if (this.frameScheduler) {
|
|
764
|
+
this.renderPausedFrame();
|
|
765
|
+
}
|
|
441
766
|
}
|
|
442
767
|
/** Start the render loop. */
|
|
443
768
|
startRenderLoop() {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
this.perfStats.renderTimes.shift();
|
|
466
|
-
}
|
|
467
|
-
this.perfStats.framesRendered++;
|
|
468
|
-
this.perfStats.fpsTimestamps.push(endTime);
|
|
469
|
-
// Check for frame drop (if render took longer than frame budget ~16.67ms for 60fps)
|
|
470
|
-
const frameBudget = 16.67;
|
|
471
|
-
if (renderTime > frameBudget) {
|
|
472
|
-
this.perfStats.framesDropped++;
|
|
473
|
-
}
|
|
474
|
-
if (outcome.warning) {
|
|
475
|
-
this.emitWarning(outcome.warning);
|
|
476
|
-
}
|
|
477
|
-
this.lastRenderedIndex = currentIndex;
|
|
478
|
-
this.lastRenderedTime = currentTime;
|
|
479
|
-
const cue = currentIndex >= 0 ? this.buildCueMetadata(currentIndex) : null;
|
|
480
|
-
this.emitCueChange(cue);
|
|
481
|
-
this.recordLastRenderInfo({
|
|
482
|
-
time: currentTime,
|
|
483
|
-
index: currentIndex,
|
|
484
|
-
status: outcome.status,
|
|
485
|
-
backend: this.currentRendererBackend,
|
|
486
|
-
usingWorker: this.getCacheStats().usingWorker,
|
|
487
|
-
cacheHit,
|
|
488
|
-
renderDuration: Math.round(renderTime * 100) / 100,
|
|
489
|
-
frameWidth: outcome.data?.width ?? null,
|
|
490
|
-
frameHeight: outcome.data?.height ?? null,
|
|
491
|
-
compositionCount: outcome.data?.compositionData.length ?? 0,
|
|
492
|
-
cue,
|
|
493
|
-
cache: this.getCacheStats(),
|
|
494
|
-
capturedAt: endTime
|
|
495
|
-
});
|
|
496
|
-
this.emitEvent({ type: 'stats', stats: this.getStats() });
|
|
497
|
-
if (currentIndex >= 0 && (this.prefetchBefore > 0 || this.prefetchAfter > 0)) {
|
|
498
|
-
const prefetch = this
|
|
499
|
-
.prefetchAroundTime;
|
|
500
|
-
prefetch?.call(this, currentTime).catch(() => { });
|
|
501
|
-
}
|
|
502
|
-
}
|
|
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;
|
|
503
790
|
}
|
|
504
|
-
this.
|
|
505
|
-
}
|
|
506
|
-
|
|
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() });
|
|
507
893
|
}
|
|
508
894
|
/** Render a subtitle frame to the canvas. */
|
|
509
895
|
renderFrame(time, index) {
|
|
510
896
|
if (!this.canvas) {
|
|
511
897
|
return { status: 'failed', data: null, warning: null };
|
|
512
898
|
}
|
|
899
|
+
if (this.useWorkerOffscreen) {
|
|
900
|
+
return this.renderFrameWorkerOffscreen(time, index);
|
|
901
|
+
}
|
|
513
902
|
// Get the data for this index
|
|
514
903
|
const data = index >= 0 ? this.renderAtIndex(index) : undefined;
|
|
515
904
|
// If data is undefined, it means async loading is in progress
|
|
@@ -546,6 +935,83 @@ class BaseVideoSubtitleRenderer {
|
|
|
546
935
|
}
|
|
547
936
|
return { status: 'rendered', data, warning: null };
|
|
548
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
|
+
}
|
|
549
1015
|
computeLayout(data) {
|
|
550
1016
|
if (!this.canvas) {
|
|
551
1017
|
return { scaleX: 1, scaleY: 1, shiftX: 0, shiftY: 0, opacity: this.displaySettings.opacity };
|
|
@@ -675,10 +1141,9 @@ class BaseVideoSubtitleRenderer {
|
|
|
675
1141
|
/** Dispose of all resources. */
|
|
676
1142
|
dispose() {
|
|
677
1143
|
this.disposed = true;
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
}
|
|
1144
|
+
this.invalidatePresentation();
|
|
1145
|
+
this.frameScheduler?.stop();
|
|
1146
|
+
this.frameScheduler = null;
|
|
682
1147
|
this.resizeObserver?.disconnect();
|
|
683
1148
|
this.resizeObserver = null;
|
|
684
1149
|
if (this.loadedMetadataHandler) {
|
|
@@ -698,7 +1163,11 @@ class BaseVideoSubtitleRenderer {
|
|
|
698
1163
|
this.webgl2Renderer.destroy();
|
|
699
1164
|
this.webgl2Renderer = null;
|
|
700
1165
|
}
|
|
701
|
-
this.
|
|
1166
|
+
const state = this.getWorkerRendererState();
|
|
1167
|
+
if (this.useWorkerOffscreen && state.sessionId) {
|
|
1168
|
+
sendToWorker({ type: 'detachOffscreenCanvas', sessionId: state.sessionId }).catch(() => { });
|
|
1169
|
+
}
|
|
1170
|
+
this.removeOwnedCanvas();
|
|
702
1171
|
this.canvas = null;
|
|
703
1172
|
this.ctx = null;
|
|
704
1173
|
this.tempCanvas = null;
|
|
@@ -707,8 +1176,13 @@ class BaseVideoSubtitleRenderer {
|
|
|
707
1176
|
this.currentCueMetadata = null;
|
|
708
1177
|
this.parserMetadata = null;
|
|
709
1178
|
this.lastRenderInfo = null;
|
|
1179
|
+
this.offscreenFrameMetadata.clear();
|
|
710
1180
|
this.useWebGPU = false;
|
|
711
1181
|
this.useWebGL2 = false;
|
|
1182
|
+
this.useWorkerOffscreen = false;
|
|
1183
|
+
this.pendingWorkerOffscreen = false;
|
|
1184
|
+
this.offscreenTransferPending = false;
|
|
1185
|
+
this.offscreenAttachPromise = null;
|
|
712
1186
|
}
|
|
713
1187
|
}
|
|
714
1188
|
/**
|
|
@@ -718,6 +1192,8 @@ class BaseVideoSubtitleRenderer {
|
|
|
718
1192
|
export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
719
1193
|
pgsParser = null;
|
|
720
1194
|
state = createWorkerState();
|
|
1195
|
+
streamOperationQueue = Promise.resolve();
|
|
1196
|
+
streamGeneration = 0;
|
|
721
1197
|
onLoading;
|
|
722
1198
|
onLoaded;
|
|
723
1199
|
onError;
|
|
@@ -741,7 +1217,9 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
741
1217
|
return;
|
|
742
1218
|
}
|
|
743
1219
|
if (!this.subUrl) {
|
|
744
|
-
|
|
1220
|
+
await this.beginPgsPushSession();
|
|
1221
|
+
this.onLoaded?.();
|
|
1222
|
+
return;
|
|
745
1223
|
}
|
|
746
1224
|
if (!this.streamingLoad) {
|
|
747
1225
|
const { data, strategy, rangeSupported, total } = await fetchSubtitleAsset(this.subUrl, {
|
|
@@ -775,6 +1253,150 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
775
1253
|
}
|
|
776
1254
|
}
|
|
777
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;
|
|
1272
|
+
}
|
|
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);
|
|
1359
|
+
}
|
|
1360
|
+
else {
|
|
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
|
+
}
|
|
778
1400
|
async loadPgsBuffer(data, preserveSource) {
|
|
779
1401
|
if (this.state.useWorker) {
|
|
780
1402
|
try {
|
|
@@ -973,6 +1595,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
973
1595
|
}
|
|
974
1596
|
if (this.state.useWorker && this.state.workerReady) {
|
|
975
1597
|
if (!this.state.pendingRenders.has(index)) {
|
|
1598
|
+
const generation = this.streamGeneration;
|
|
976
1599
|
const renderTask = sendToWorker({
|
|
977
1600
|
type: 'renderPgsAtIndex',
|
|
978
1601
|
sessionId: this.state.sessionId,
|
|
@@ -990,15 +1613,14 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
990
1613
|
this.state.pendingRenders.set(index, renderPromise);
|
|
991
1614
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
992
1615
|
renderTask.then(({ frame, renderIssue }) => {
|
|
1616
|
+
if (generation !== this.streamGeneration || this.disposed)
|
|
1617
|
+
return;
|
|
993
1618
|
setCachedFrame(this.state, index, frame, renderIssue);
|
|
994
1619
|
this.state.pendingRenders.delete(index);
|
|
995
1620
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
996
|
-
// Force re-render on next frame by resetting lastRenderedIndex
|
|
997
|
-
if (this.findCurrentIndex(this.video.currentTime + this.timeOffset) === index) {
|
|
998
|
-
this.lastRenderedIndex = -1;
|
|
999
|
-
}
|
|
1000
1621
|
});
|
|
1001
1622
|
}
|
|
1623
|
+
this.watchPendingRender(index, this.state.pendingRenders.get(index));
|
|
1002
1624
|
// Return undefined to indicate async loading in progress
|
|
1003
1625
|
return undefined;
|
|
1004
1626
|
}
|
|
@@ -1017,6 +1639,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1017
1639
|
const startTime = this.state.timestamps[index];
|
|
1018
1640
|
const endTime = this.state.timestamps[index + 1] ?? startTime + 5000;
|
|
1019
1641
|
const frame = this.state.frameCache.get(index) ?? null;
|
|
1642
|
+
const offscreenFrame = this.getOffscreenFrameMetadata(index);
|
|
1020
1643
|
return {
|
|
1021
1644
|
index,
|
|
1022
1645
|
format: 'pgs',
|
|
@@ -1025,8 +1648,8 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1025
1648
|
duration: Math.max(0, endTime - startTime),
|
|
1026
1649
|
screenWidth: metadata.screenWidth,
|
|
1027
1650
|
screenHeight: metadata.screenHeight,
|
|
1028
|
-
bounds: frame ? getSubtitleBounds(frame) : null,
|
|
1029
|
-
compositionCount: frame?.compositionData.length ?? 0
|
|
1651
|
+
bounds: frame ? getSubtitleBounds(frame) : (offscreenFrame?.bounds ?? null),
|
|
1652
|
+
compositionCount: frame?.compositionData.length ?? offscreenFrame?.compositionCount ?? 0
|
|
1030
1653
|
};
|
|
1031
1654
|
}
|
|
1032
1655
|
isPendingRender(index) {
|
|
@@ -1047,15 +1670,18 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1047
1670
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
1048
1671
|
}
|
|
1049
1672
|
clearFrameCache() {
|
|
1673
|
+
this.invalidatePresentation();
|
|
1050
1674
|
this.state.frameCache.clear();
|
|
1051
1675
|
this.state.renderIssues.clear();
|
|
1052
1676
|
this.state.pendingRenders.clear();
|
|
1677
|
+
this.clearOffscreenFrameMetadata();
|
|
1053
1678
|
this.lastRenderedIndex = -1;
|
|
1054
1679
|
if (this.state.useWorker && this.state.workerReady) {
|
|
1055
1680
|
sendToWorker({ type: 'clearPgsCache', sessionId: this.state.sessionId }).catch(() => { });
|
|
1056
1681
|
}
|
|
1057
1682
|
this.pgsParser?.clearCache();
|
|
1058
1683
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
1684
|
+
this.renderPausedFrame();
|
|
1059
1685
|
}
|
|
1060
1686
|
async prefetchRange(startIndex, endIndex) {
|
|
1061
1687
|
const safeStart = Math.max(0, Math.min(startIndex, endIndex));
|
|
@@ -1087,6 +1713,7 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1087
1713
|
};
|
|
1088
1714
|
}
|
|
1089
1715
|
dispose() {
|
|
1716
|
+
this.streamGeneration++;
|
|
1090
1717
|
super.dispose();
|
|
1091
1718
|
this.state.frameCache.clear();
|
|
1092
1719
|
this.state.renderIssues.clear();
|
|
@@ -1427,11 +2054,14 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1427
2054
|
}
|
|
1428
2055
|
// Start async lookup if not already pending
|
|
1429
2056
|
if (!this.pendingIndexLookup) {
|
|
1430
|
-
|
|
2057
|
+
const presentationToken = this.getPresentationToken();
|
|
2058
|
+
const lookup = sendToWorker({
|
|
1431
2059
|
type: 'findVobSubIndex',
|
|
1432
2060
|
sessionId: this.state.sessionId,
|
|
1433
2061
|
timeMs
|
|
1434
2062
|
}).then((response) => {
|
|
2063
|
+
if (!this.isCurrentPresentation(presentationToken))
|
|
2064
|
+
return this.cachedIndex;
|
|
1435
2065
|
if (response.type === 'vobSubIndex') {
|
|
1436
2066
|
const newIndex = response.index;
|
|
1437
2067
|
const oldIndex = this.cachedIndex;
|
|
@@ -1442,9 +2072,18 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1442
2072
|
this.lastRenderedIndex = -2; // Use -2 to force update even when new index is -1
|
|
1443
2073
|
}
|
|
1444
2074
|
}
|
|
1445
|
-
this.pendingIndexLookup = null;
|
|
1446
2075
|
return this.cachedIndex;
|
|
1447
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
|
+
});
|
|
1448
2087
|
}
|
|
1449
2088
|
return this.cachedIndex;
|
|
1450
2089
|
}
|
|
@@ -1477,12 +2116,9 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1477
2116
|
setCachedFrame(this.state, index, frame, renderIssue);
|
|
1478
2117
|
this.state.pendingRenders.delete(index);
|
|
1479
2118
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
1480
|
-
// Force re-render on next frame by resetting lastRenderedIndex
|
|
1481
|
-
if (this.findCurrentIndex(this.video.currentTime + this.timeOffset) === index) {
|
|
1482
|
-
this.lastRenderedIndex = -1;
|
|
1483
|
-
}
|
|
1484
2119
|
});
|
|
1485
2120
|
}
|
|
2121
|
+
this.watchPendingRender(index, this.state.pendingRenders.get(index));
|
|
1486
2122
|
// Return undefined to indicate async loading in progress
|
|
1487
2123
|
return undefined;
|
|
1488
2124
|
}
|
|
@@ -1501,6 +2137,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1501
2137
|
const startTime = this.state.timestamps[index];
|
|
1502
2138
|
const endTime = this.state.timestamps[index + 1] ?? startTime + 5000;
|
|
1503
2139
|
const frame = this.state.frameCache.get(index) ?? null;
|
|
2140
|
+
const offscreenFrame = this.getOffscreenFrameMetadata(index);
|
|
1504
2141
|
return {
|
|
1505
2142
|
index,
|
|
1506
2143
|
format: 'vobsub',
|
|
@@ -1509,8 +2146,8 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1509
2146
|
duration: Math.max(0, endTime - startTime),
|
|
1510
2147
|
screenWidth: metadata.screenWidth,
|
|
1511
2148
|
screenHeight: metadata.screenHeight,
|
|
1512
|
-
bounds: frame ? getSubtitleBounds(frame) : null,
|
|
1513
|
-
compositionCount: frame?.compositionData.length ?? 0,
|
|
2149
|
+
bounds: frame ? getSubtitleBounds(frame) : (offscreenFrame?.bounds ?? null),
|
|
2150
|
+
compositionCount: frame?.compositionData.length ?? offscreenFrame?.compositionCount ?? 0,
|
|
1514
2151
|
language: metadata.language ?? null,
|
|
1515
2152
|
trackId: metadata.trackId ?? null
|
|
1516
2153
|
};
|
|
@@ -1537,9 +2174,11 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1537
2174
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
1538
2175
|
}
|
|
1539
2176
|
clearFrameCache() {
|
|
2177
|
+
this.invalidatePresentation();
|
|
1540
2178
|
this.state.frameCache.clear();
|
|
1541
2179
|
this.state.renderIssues.clear();
|
|
1542
2180
|
this.state.pendingRenders.clear();
|
|
2181
|
+
this.clearOffscreenFrameMetadata();
|
|
1543
2182
|
this.cachedIndex = -1;
|
|
1544
2183
|
this.cachedIndexTime = -1;
|
|
1545
2184
|
this.pendingIndexLookup = null;
|
|
@@ -1549,6 +2188,7 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1549
2188
|
}
|
|
1550
2189
|
this.vobsubParser?.clearCache();
|
|
1551
2190
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
2191
|
+
this.renderPausedFrame();
|
|
1552
2192
|
}
|
|
1553
2193
|
async prefetchRange(startIndex, endIndex) {
|
|
1554
2194
|
const safeStart = Math.max(0, Math.min(startIndex, endIndex));
|
|
@@ -1585,10 +2225,13 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1585
2225
|
sendToWorker({ type: 'setVobSubDebandEnabled', sessionId: this.state.sessionId, enabled }).catch(() => { });
|
|
1586
2226
|
}
|
|
1587
2227
|
this.vobsubParser?.setDebandEnabled(enabled);
|
|
2228
|
+
this.invalidatePresentation();
|
|
1588
2229
|
// Clear cache to force re-render with new settings
|
|
1589
2230
|
this.state.frameCache.clear();
|
|
2231
|
+
this.clearOffscreenFrameMetadata();
|
|
1590
2232
|
this.lastRenderedIndex = -1;
|
|
1591
2233
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
2234
|
+
this.renderPausedFrame();
|
|
1592
2235
|
}
|
|
1593
2236
|
/** Set debanding threshold (0-255, default: 64) */
|
|
1594
2237
|
setDebandThreshold(threshold) {
|
|
@@ -1596,10 +2239,13 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1596
2239
|
sendToWorker({ type: 'setVobSubDebandThreshold', sessionId: this.state.sessionId, threshold }).catch(() => { });
|
|
1597
2240
|
}
|
|
1598
2241
|
this.vobsubParser?.setDebandThreshold(threshold);
|
|
2242
|
+
this.invalidatePresentation();
|
|
1599
2243
|
// Clear cache to force re-render with new settings
|
|
1600
2244
|
this.state.frameCache.clear();
|
|
2245
|
+
this.clearOffscreenFrameMetadata();
|
|
1601
2246
|
this.lastRenderedIndex = -1;
|
|
1602
2247
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
2248
|
+
this.renderPausedFrame();
|
|
1603
2249
|
}
|
|
1604
2250
|
/** Set debanding sample range in pixels (1-64, default: 15) */
|
|
1605
2251
|
setDebandRange(range) {
|
|
@@ -1607,10 +2253,13 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1607
2253
|
sendToWorker({ type: 'setVobSubDebandRange', sessionId: this.state.sessionId, range }).catch(() => { });
|
|
1608
2254
|
}
|
|
1609
2255
|
this.vobsubParser?.setDebandRange(range);
|
|
2256
|
+
this.invalidatePresentation();
|
|
1610
2257
|
// Clear cache to force re-render with new settings
|
|
1611
2258
|
this.state.frameCache.clear();
|
|
2259
|
+
this.clearOffscreenFrameMetadata();
|
|
1612
2260
|
this.lastRenderedIndex = -1;
|
|
1613
2261
|
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
2262
|
+
this.renderPausedFrame();
|
|
1614
2263
|
}
|
|
1615
2264
|
/** Check if debanding is enabled */
|
|
1616
2265
|
get debandEnabled() {
|
|
@@ -1629,17 +2278,571 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
1629
2278
|
this.state.sessionId = null;
|
|
1630
2279
|
}
|
|
1631
2280
|
}
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
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
|
+
*/
|
|
2832
|
+
/** Create a video subtitle renderer with automatic format detection. */
|
|
2833
|
+
export function createAutoSubtitleRenderer(options) {
|
|
2834
|
+
const format = detectSubtitleFormat({
|
|
2835
|
+
data: options.subContent,
|
|
2836
|
+
idxContent: options.idxContent,
|
|
2837
|
+
fileName: options.fileName,
|
|
2838
|
+
subUrl: options.subUrl,
|
|
2839
|
+
idxUrl: options.idxUrl
|
|
2840
|
+
});
|
|
2841
|
+
if (format === 'pgs') {
|
|
2842
|
+
return new PgsRenderer(options);
|
|
2843
|
+
}
|
|
2844
|
+
if (format === 'dvb') {
|
|
2845
|
+
return new DvbRenderer(options);
|
|
1643
2846
|
}
|
|
1644
2847
|
if (format === 'vobsub') {
|
|
1645
2848
|
return new VobSubRenderer(options);
|