libbitsub 1.5.1 → 1.7.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 +262 -223
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/ts/parsers.d.ts +18 -1
- package/dist/ts/parsers.d.ts.map +1 -1
- package/dist/ts/parsers.js +151 -0
- package/dist/ts/parsers.js.map +1 -1
- package/dist/ts/renderers.d.ts +54 -4
- package/dist/ts/renderers.d.ts.map +1 -1
- package/dist/ts/renderers.js +457 -87
- package/dist/ts/renderers.js.map +1 -1
- package/dist/ts/types.d.ts +147 -3
- package/dist/ts/types.d.ts.map +1 -1
- package/dist/ts/utils.d.ts +11 -1
- package/dist/ts/utils.d.ts.map +1 -1
- package/dist/ts/utils.js +100 -1
- package/dist/ts/utils.js.map +1 -1
- package/dist/ts/wasm.js.map +1 -1
- package/dist/ts/webgl2-renderer.d.ts +57 -0
- package/dist/ts/webgl2-renderer.d.ts.map +1 -0
- package/dist/ts/webgl2-renderer.js +293 -0
- package/dist/ts/webgl2-renderer.js.map +1 -0
- package/dist/ts/webgpu-renderer.d.ts +5 -1
- package/dist/ts/webgpu-renderer.d.ts.map +1 -1
- package/dist/ts/webgpu-renderer.js +64 -45
- package/dist/ts/webgpu-renderer.js.map +1 -1
- package/dist/ts/worker.d.ts.map +1 -1
- package/dist/ts/worker.js +145 -87
- package/dist/ts/worker.js.map +1 -1
- package/dist/wrapper.d.ts +3 -2
- package/dist/wrapper.d.ts.map +1 -1
- package/dist/wrapper.js +3 -1
- package/dist/wrapper.js.map +1 -1
- package/package.json +3 -2
- package/pkg/README.md +262 -223
- package/pkg/libbitsub.d.ts +120 -0
- package/pkg/libbitsub.js +251 -15
- package/pkg/libbitsub_bg.wasm +0 -0
- package/pkg/libbitsub_bg.wasm.d.ts +24 -0
- package/pkg/package.json +1 -1
- package/src/wrapper.ts +14 -1
package/dist/ts/renderers.js
CHANGED
|
@@ -4,13 +4,19 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { initWasm } from './wasm';
|
|
6
6
|
import { getOrCreateWorker, sendToWorker } from './worker';
|
|
7
|
-
import { binarySearchTimestamp, convertFrameData, createWorkerState } from './utils';
|
|
7
|
+
import { binarySearchTimestamp, convertFrameData, createWorkerSessionId, createWorkerState, detectSubtitleFormat, getSubtitleBounds, setCacheLimit as applyCacheLimit, setCachedFrame } from './utils';
|
|
8
8
|
import { PgsParser, VobSubParserLowLevel } from './parsers';
|
|
9
9
|
import { WebGPURenderer, isWebGPUSupported } from './webgpu-renderer';
|
|
10
|
+
import { WebGL2Renderer, isWebGL2Supported } from './webgl2-renderer';
|
|
10
11
|
/** Default display settings */
|
|
11
12
|
const DEFAULT_DISPLAY_SETTINGS = {
|
|
12
13
|
scale: 1.0,
|
|
13
|
-
verticalOffset: 0
|
|
14
|
+
verticalOffset: 0,
|
|
15
|
+
horizontalOffset: 0,
|
|
16
|
+
horizontalAlign: 'center',
|
|
17
|
+
bottomPadding: 0,
|
|
18
|
+
safeArea: 0,
|
|
19
|
+
opacity: 1.0
|
|
14
20
|
};
|
|
15
21
|
/**
|
|
16
22
|
* Base class for video-integrated subtitle renderers.
|
|
@@ -18,6 +24,7 @@ const DEFAULT_DISPLAY_SETTINGS = {
|
|
|
18
24
|
*/
|
|
19
25
|
class BaseVideoSubtitleRenderer {
|
|
20
26
|
video;
|
|
27
|
+
format;
|
|
21
28
|
subUrl;
|
|
22
29
|
subContent;
|
|
23
30
|
canvas = null;
|
|
@@ -31,13 +38,26 @@ class BaseVideoSubtitleRenderer {
|
|
|
31
38
|
tempCanvas = null;
|
|
32
39
|
tempCtx = null;
|
|
33
40
|
lastRenderedData = null;
|
|
41
|
+
lastCueIndex = null;
|
|
42
|
+
currentCueMetadata = null;
|
|
43
|
+
parserMetadata = null;
|
|
34
44
|
/** Display settings for subtitle rendering */
|
|
35
45
|
displaySettings = { ...DEFAULT_DISPLAY_SETTINGS };
|
|
36
|
-
|
|
46
|
+
cacheLimit = 24;
|
|
47
|
+
prefetchBefore = 0;
|
|
48
|
+
prefetchAfter = 0;
|
|
49
|
+
onEvent;
|
|
50
|
+
currentRendererBackend = null;
|
|
51
|
+
loadedMetadataHandler = null;
|
|
52
|
+
seekedHandler = null;
|
|
53
|
+
// WebGPU renderer (optional, falls back to WebGL2 then Canvas2D)
|
|
37
54
|
webgpuRenderer = null;
|
|
38
55
|
useWebGPU = false;
|
|
39
|
-
preferWebGPU = true;
|
|
40
56
|
onWebGPUFallback;
|
|
57
|
+
// WebGL2 renderer (optional, falls back to Canvas2D)
|
|
58
|
+
webgl2Renderer = null;
|
|
59
|
+
useWebGL2 = false;
|
|
60
|
+
onWebGL2Fallback;
|
|
41
61
|
// Performance tracking
|
|
42
62
|
perfStats = {
|
|
43
63
|
framesRendered: 0,
|
|
@@ -47,17 +67,39 @@ class BaseVideoSubtitleRenderer {
|
|
|
47
67
|
fpsTimestamps: [],
|
|
48
68
|
lastFrameTime: 0
|
|
49
69
|
};
|
|
50
|
-
constructor(options) {
|
|
70
|
+
constructor(options, format) {
|
|
51
71
|
this.video = options.video;
|
|
72
|
+
this.format = format;
|
|
52
73
|
this.subUrl = options.subUrl;
|
|
53
74
|
this.subContent = options.subContent;
|
|
54
|
-
this.preferWebGPU = options.preferWebGPU !== false; // Default to true
|
|
55
75
|
this.onWebGPUFallback = options.onWebGPUFallback;
|
|
76
|
+
this.onWebGL2Fallback = options.onWebGL2Fallback;
|
|
77
|
+
this.onEvent = options.onEvent;
|
|
78
|
+
this.displaySettings = { ...DEFAULT_DISPLAY_SETTINGS, ...options.displaySettings };
|
|
79
|
+
this.cacheLimit = Math.max(0, Math.floor(options.cacheLimit ?? 24));
|
|
80
|
+
this.prefetchBefore = Math.max(0, Math.floor(options.prefetchWindow?.before ?? 0));
|
|
81
|
+
this.prefetchAfter = Math.max(0, Math.floor(options.prefetchWindow?.after ?? 0));
|
|
56
82
|
}
|
|
57
83
|
/** Get current display settings */
|
|
58
84
|
getDisplaySettings() {
|
|
59
85
|
return { ...this.displaySettings };
|
|
60
86
|
}
|
|
87
|
+
/** Get parser metadata for the active subtitle track. */
|
|
88
|
+
getMetadata() {
|
|
89
|
+
return this.parserMetadata;
|
|
90
|
+
}
|
|
91
|
+
/** Get the most recently displayed cue metadata. */
|
|
92
|
+
getCurrentCueMetadata() {
|
|
93
|
+
return this.currentCueMetadata;
|
|
94
|
+
}
|
|
95
|
+
/** Get cue metadata for the specified index. */
|
|
96
|
+
getCueMetadata(index) {
|
|
97
|
+
return this.buildCueMetadata(index);
|
|
98
|
+
}
|
|
99
|
+
/** Get the configured frame-cache limit. */
|
|
100
|
+
getCacheLimit() {
|
|
101
|
+
return this.cacheLimit;
|
|
102
|
+
}
|
|
61
103
|
/** Get base stats common to all renderers */
|
|
62
104
|
getBaseStats() {
|
|
63
105
|
const now = performance.now();
|
|
@@ -80,13 +122,18 @@ class BaseVideoSubtitleRenderer {
|
|
|
80
122
|
}
|
|
81
123
|
/** Set display settings and force re-render */
|
|
82
124
|
setDisplaySettings(settings) {
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
125
|
+
const nextSettings = {
|
|
126
|
+
...this.displaySettings,
|
|
127
|
+
...settings
|
|
128
|
+
};
|
|
129
|
+
nextSettings.scale = Math.max(0.1, Math.min(3.0, nextSettings.scale));
|
|
130
|
+
nextSettings.verticalOffset = Math.max(-50, Math.min(50, nextSettings.verticalOffset));
|
|
131
|
+
nextSettings.horizontalOffset = Math.max(-50, Math.min(50, nextSettings.horizontalOffset));
|
|
132
|
+
nextSettings.bottomPadding = Math.max(0, Math.min(50, nextSettings.bottomPadding));
|
|
133
|
+
nextSettings.safeArea = Math.max(0, Math.min(25, nextSettings.safeArea));
|
|
134
|
+
nextSettings.opacity = Math.max(0, Math.min(1, nextSettings.opacity));
|
|
135
|
+
const changed = JSON.stringify(nextSettings) !== JSON.stringify(this.displaySettings);
|
|
136
|
+
this.displaySettings = nextSettings;
|
|
90
137
|
// Force re-render if settings changed
|
|
91
138
|
if (changed) {
|
|
92
139
|
this.lastRenderedIndex = -1;
|
|
@@ -101,7 +148,9 @@ class BaseVideoSubtitleRenderer {
|
|
|
101
148
|
}
|
|
102
149
|
/** Start initialization. */
|
|
103
150
|
startInit() {
|
|
104
|
-
this.init()
|
|
151
|
+
this.init().catch((error) => {
|
|
152
|
+
this.emitEvent({ type: 'error', format: this.format, error: error instanceof Error ? error : new Error(String(error)) });
|
|
153
|
+
});
|
|
105
154
|
}
|
|
106
155
|
/** Initialize the renderer. */
|
|
107
156
|
async init() {
|
|
@@ -126,22 +175,57 @@ class BaseVideoSubtitleRenderer {
|
|
|
126
175
|
}
|
|
127
176
|
parent.appendChild(this.canvas);
|
|
128
177
|
}
|
|
129
|
-
// Try WebGPU first
|
|
130
|
-
if (
|
|
178
|
+
// Try WebGPU first, then WebGL2, then Canvas2D
|
|
179
|
+
if (isWebGPUSupported()) {
|
|
131
180
|
this.initWebGPU();
|
|
132
181
|
}
|
|
182
|
+
else if (isWebGL2Supported()) {
|
|
183
|
+
this.initWebGL2();
|
|
184
|
+
}
|
|
133
185
|
else {
|
|
134
186
|
this.initCanvas2D();
|
|
135
187
|
}
|
|
136
188
|
this.updateCanvasSize();
|
|
137
189
|
this.resizeObserver = new ResizeObserver(() => this.updateCanvasSize());
|
|
138
190
|
this.resizeObserver.observe(this.video);
|
|
139
|
-
this.
|
|
140
|
-
this.
|
|
191
|
+
this.loadedMetadataHandler = () => this.updateCanvasSize();
|
|
192
|
+
this.seekedHandler = () => {
|
|
141
193
|
this.lastRenderedIndex = -1;
|
|
142
194
|
this.lastRenderedTime = -1;
|
|
143
195
|
this.onSeek();
|
|
144
|
-
}
|
|
196
|
+
};
|
|
197
|
+
this.video.addEventListener('loadedmetadata', this.loadedMetadataHandler);
|
|
198
|
+
this.video.addEventListener('seeked', this.seekedHandler);
|
|
199
|
+
}
|
|
200
|
+
emitEvent(event) {
|
|
201
|
+
this.onEvent?.(event);
|
|
202
|
+
}
|
|
203
|
+
setParserMetadata(metadata) {
|
|
204
|
+
this.parserMetadata = metadata;
|
|
205
|
+
if (metadata) {
|
|
206
|
+
this.emitEvent({ type: 'loaded', format: this.format, metadata });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
emitWorkerState(enabled, ready, sessionId, fallback = false) {
|
|
210
|
+
this.emitEvent({ type: 'worker-state', enabled, ready, sessionId, fallback });
|
|
211
|
+
}
|
|
212
|
+
emitCacheChange(cachedFrames, pendingRenders) {
|
|
213
|
+
this.emitEvent({ type: 'cache-change', cachedFrames, pendingRenders, cacheLimit: this.cacheLimit });
|
|
214
|
+
}
|
|
215
|
+
emitCueChange(cue) {
|
|
216
|
+
if (this.lastCueIndex === cue?.index && cue?.index !== undefined) {
|
|
217
|
+
this.currentCueMetadata = cue;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this.lastCueIndex = cue?.index ?? null;
|
|
221
|
+
this.currentCueMetadata = cue;
|
|
222
|
+
this.emitEvent({ type: 'cue-change', cue });
|
|
223
|
+
}
|
|
224
|
+
emitRendererBackend(renderer) {
|
|
225
|
+
if (this.currentRendererBackend === renderer)
|
|
226
|
+
return;
|
|
227
|
+
this.currentRendererBackend = renderer;
|
|
228
|
+
this.emitEvent({ type: 'renderer-change', renderer });
|
|
145
229
|
}
|
|
146
230
|
/** Initialize WebGPU renderer. */
|
|
147
231
|
async initWebGPU() {
|
|
@@ -155,15 +239,42 @@ class BaseVideoSubtitleRenderer {
|
|
|
155
239
|
const height = Math.max(1, bounds.height * window.devicePixelRatio);
|
|
156
240
|
await this.webgpuRenderer.setCanvas(this.canvas, width, height);
|
|
157
241
|
this.useWebGPU = true;
|
|
158
|
-
|
|
242
|
+
this.emitRendererBackend('webgpu');
|
|
159
243
|
}
|
|
160
244
|
catch (error) {
|
|
161
|
-
console.warn('[libbitsub] WebGPU init failed, falling back to Canvas2D:', error);
|
|
162
245
|
this.webgpuRenderer?.destroy();
|
|
163
246
|
this.webgpuRenderer = null;
|
|
164
247
|
this.useWebGPU = false;
|
|
165
|
-
this.initCanvas2D();
|
|
166
248
|
this.onWebGPUFallback?.();
|
|
249
|
+
// Try WebGL2 before Canvas2D
|
|
250
|
+
if (isWebGL2Supported()) {
|
|
251
|
+
this.initWebGL2();
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
this.initCanvas2D();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/** Initialize WebGL2 renderer. */
|
|
259
|
+
async initWebGL2() {
|
|
260
|
+
try {
|
|
261
|
+
this.webgl2Renderer = new WebGL2Renderer();
|
|
262
|
+
await this.webgl2Renderer.init();
|
|
263
|
+
if (!this.canvas)
|
|
264
|
+
return;
|
|
265
|
+
const bounds = this.getVideoContentBounds();
|
|
266
|
+
const width = Math.max(1, bounds.width * window.devicePixelRatio);
|
|
267
|
+
const height = Math.max(1, bounds.height * window.devicePixelRatio);
|
|
268
|
+
await this.webgl2Renderer.setCanvas(this.canvas, width, height);
|
|
269
|
+
this.useWebGL2 = true;
|
|
270
|
+
this.emitRendererBackend('webgl2');
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
this.webgl2Renderer?.destroy();
|
|
274
|
+
this.webgl2Renderer = null;
|
|
275
|
+
this.useWebGL2 = false;
|
|
276
|
+
this.onWebGL2Fallback?.();
|
|
277
|
+
this.initCanvas2D();
|
|
167
278
|
}
|
|
168
279
|
}
|
|
169
280
|
/** Initialize Canvas2D renderer. */
|
|
@@ -172,7 +283,8 @@ class BaseVideoSubtitleRenderer {
|
|
|
172
283
|
return;
|
|
173
284
|
this.ctx = this.canvas.getContext('2d');
|
|
174
285
|
this.useWebGPU = false;
|
|
175
|
-
|
|
286
|
+
this.useWebGL2 = false;
|
|
287
|
+
this.emitRendererBackend('canvas2d');
|
|
176
288
|
}
|
|
177
289
|
/** Called when video seeks. */
|
|
178
290
|
onSeek() { }
|
|
@@ -227,10 +339,13 @@ class BaseVideoSubtitleRenderer {
|
|
|
227
339
|
this.canvas.style.top = `${bounds.y}px`;
|
|
228
340
|
this.canvas.style.width = `${bounds.width}px`;
|
|
229
341
|
this.canvas.style.height = `${bounds.height}px`;
|
|
230
|
-
// Update
|
|
342
|
+
// Update GPU renderer size if active
|
|
231
343
|
if (this.useWebGPU && this.webgpuRenderer) {
|
|
232
344
|
this.webgpuRenderer.updateSize(pixelWidth, pixelHeight);
|
|
233
345
|
}
|
|
346
|
+
else if (this.useWebGL2 && this.webgl2Renderer) {
|
|
347
|
+
this.webgl2Renderer.updateSize(pixelWidth, pixelHeight);
|
|
348
|
+
}
|
|
234
349
|
this.lastRenderedIndex = -1;
|
|
235
350
|
this.lastRenderedTime = -1;
|
|
236
351
|
}
|
|
@@ -267,6 +382,12 @@ class BaseVideoSubtitleRenderer {
|
|
|
267
382
|
}
|
|
268
383
|
this.lastRenderedIndex = currentIndex;
|
|
269
384
|
this.lastRenderedTime = currentTime;
|
|
385
|
+
this.emitCueChange(currentIndex >= 0 ? this.buildCueMetadata(currentIndex) : null);
|
|
386
|
+
this.emitEvent({ type: 'stats', stats: this.getStats() });
|
|
387
|
+
if (currentIndex >= 0 && (this.prefetchBefore > 0 || this.prefetchAfter > 0)) {
|
|
388
|
+
const prefetch = this.prefetchAroundTime;
|
|
389
|
+
prefetch?.call(this, currentTime).catch(() => { });
|
|
390
|
+
}
|
|
270
391
|
}
|
|
271
392
|
}
|
|
272
393
|
this.animationFrameId = requestAnimationFrame(render);
|
|
@@ -290,14 +411,70 @@ class BaseVideoSubtitleRenderer {
|
|
|
290
411
|
return;
|
|
291
412
|
}
|
|
292
413
|
}
|
|
293
|
-
// Use
|
|
414
|
+
// Use best available renderer
|
|
294
415
|
if (this.useWebGPU && this.webgpuRenderer) {
|
|
295
416
|
this.renderFrameWebGPU(data, index);
|
|
296
417
|
}
|
|
418
|
+
else if (this.useWebGL2 && this.webgl2Renderer) {
|
|
419
|
+
this.renderFrameWebGL2(data, index);
|
|
420
|
+
}
|
|
297
421
|
else {
|
|
298
422
|
this.renderFrameCanvas2D(data, index);
|
|
299
423
|
}
|
|
300
424
|
}
|
|
425
|
+
computeLayout(data) {
|
|
426
|
+
if (!this.canvas) {
|
|
427
|
+
return { scaleX: 1, scaleY: 1, shiftX: 0, shiftY: 0, opacity: this.displaySettings.opacity };
|
|
428
|
+
}
|
|
429
|
+
const baseScaleX = this.canvas.width / data.width;
|
|
430
|
+
const baseScaleY = this.canvas.height / data.height;
|
|
431
|
+
const bounds = getSubtitleBounds(data);
|
|
432
|
+
const { scale, verticalOffset, horizontalOffset, horizontalAlign, bottomPadding, safeArea, opacity } = this.displaySettings;
|
|
433
|
+
if (!bounds) {
|
|
434
|
+
return {
|
|
435
|
+
scaleX: baseScaleX * scale,
|
|
436
|
+
scaleY: baseScaleY * scale,
|
|
437
|
+
shiftX: (horizontalOffset / 100) * this.canvas.width,
|
|
438
|
+
shiftY: (verticalOffset / 100) * this.canvas.height - (bottomPadding / 100) * this.canvas.height,
|
|
439
|
+
opacity
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
const groupWidth = bounds.width * baseScaleX;
|
|
443
|
+
const groupHeight = bounds.height * baseScaleY;
|
|
444
|
+
const scaledGroupWidth = groupWidth * scale;
|
|
445
|
+
const scaledGroupHeight = groupHeight * scale;
|
|
446
|
+
let anchorShiftX = 0;
|
|
447
|
+
if (horizontalAlign === 'center') {
|
|
448
|
+
anchorShiftX = (groupWidth - scaledGroupWidth) / 2;
|
|
449
|
+
}
|
|
450
|
+
else if (horizontalAlign === 'right') {
|
|
451
|
+
anchorShiftX = groupWidth - scaledGroupWidth;
|
|
452
|
+
}
|
|
453
|
+
let shiftX = anchorShiftX + (horizontalOffset / 100) * this.canvas.width;
|
|
454
|
+
let shiftY = groupHeight - scaledGroupHeight + (verticalOffset / 100) * this.canvas.height;
|
|
455
|
+
shiftY -= (bottomPadding / 100) * this.canvas.height;
|
|
456
|
+
const safeX = (safeArea / 100) * this.canvas.width;
|
|
457
|
+
const safeY = (safeArea / 100) * this.canvas.height;
|
|
458
|
+
const finalMinX = bounds.x * baseScaleX + shiftX;
|
|
459
|
+
const finalMinY = bounds.y * baseScaleY + shiftY;
|
|
460
|
+
const finalMaxX = finalMinX + scaledGroupWidth;
|
|
461
|
+
const finalMaxY = finalMinY + scaledGroupHeight;
|
|
462
|
+
if (finalMinX < safeX)
|
|
463
|
+
shiftX += safeX - finalMinX;
|
|
464
|
+
if (finalMaxX > this.canvas.width - safeX)
|
|
465
|
+
shiftX -= finalMaxX - (this.canvas.width - safeX);
|
|
466
|
+
if (finalMinY < safeY)
|
|
467
|
+
shiftY += safeY - finalMinY;
|
|
468
|
+
if (finalMaxY > this.canvas.height - safeY)
|
|
469
|
+
shiftY -= finalMaxY - (this.canvas.height - safeY);
|
|
470
|
+
return {
|
|
471
|
+
scaleX: baseScaleX * scale,
|
|
472
|
+
scaleY: baseScaleY * scale,
|
|
473
|
+
shiftX,
|
|
474
|
+
shiftY,
|
|
475
|
+
opacity
|
|
476
|
+
};
|
|
477
|
+
}
|
|
301
478
|
/** Render using WebGPU. */
|
|
302
479
|
renderFrameWebGPU(data, index) {
|
|
303
480
|
if (!this.webgpuRenderer || !this.canvas)
|
|
@@ -311,14 +488,21 @@ class BaseVideoSubtitleRenderer {
|
|
|
311
488
|
// Store for potential reuse
|
|
312
489
|
this.lastRenderedData = data;
|
|
313
490
|
// Calculate base scale factors
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
491
|
+
const layout = this.computeLayout(data);
|
|
492
|
+
this.webgpuRenderer.render(data.compositionData, data.width, data.height, layout.scaleX, layout.scaleY, layout.shiftX, layout.shiftY, layout.opacity);
|
|
493
|
+
}
|
|
494
|
+
/** Render using WebGL2. */
|
|
495
|
+
renderFrameWebGL2(data, index) {
|
|
496
|
+
if (!this.webgl2Renderer || !this.canvas)
|
|
497
|
+
return;
|
|
498
|
+
if (index < 0 || !data || data.compositionData.length === 0) {
|
|
499
|
+
this.webgl2Renderer.clear();
|
|
500
|
+
this.lastRenderedData = null;
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
this.lastRenderedData = data;
|
|
504
|
+
const layout = this.computeLayout(data);
|
|
505
|
+
this.webgl2Renderer.render(data.compositionData, data.width, data.height, layout.scaleX, layout.scaleY, layout.shiftX, layout.shiftY, layout.opacity);
|
|
322
506
|
}
|
|
323
507
|
/** Render using Canvas2D. */
|
|
324
508
|
renderFrameCanvas2D(data, index) {
|
|
@@ -333,14 +517,9 @@ class BaseVideoSubtitleRenderer {
|
|
|
333
517
|
}
|
|
334
518
|
// Store for potential reuse
|
|
335
519
|
this.lastRenderedData = data;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
// Apply display settings
|
|
340
|
-
const { scale, verticalOffset } = this.displaySettings;
|
|
341
|
-
const scaleX = baseScaleX * scale;
|
|
342
|
-
const scaleY = baseScaleY * scale;
|
|
343
|
-
const offsetY = (verticalOffset / 100) * this.canvas.height;
|
|
520
|
+
const layout = this.computeLayout(data);
|
|
521
|
+
this.ctx.save();
|
|
522
|
+
this.ctx.globalAlpha = layout.opacity;
|
|
344
523
|
for (const comp of data.compositionData) {
|
|
345
524
|
if (!this.tempCanvas || !this.tempCtx)
|
|
346
525
|
continue;
|
|
@@ -352,14 +531,13 @@ class BaseVideoSubtitleRenderer {
|
|
|
352
531
|
this.tempCtx.putImageData(comp.pixelData, 0, 0);
|
|
353
532
|
// Calculate position with scale and offset applied
|
|
354
533
|
// Center the scaled content horizontally
|
|
355
|
-
const scaledWidth = comp.pixelData.width * scaleX;
|
|
356
|
-
const scaledHeight = comp.pixelData.height * scaleY;
|
|
357
|
-
const
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
const adjustedY = baseY + offsetY + (comp.pixelData.height * baseScaleY - scaledHeight);
|
|
361
|
-
this.ctx.drawImage(this.tempCanvas, centeredX, adjustedY, scaledWidth, scaledHeight);
|
|
534
|
+
const scaledWidth = comp.pixelData.width * layout.scaleX;
|
|
535
|
+
const scaledHeight = comp.pixelData.height * layout.scaleY;
|
|
536
|
+
const adjustedX = comp.x * (this.canvas.width / data.width) + layout.shiftX;
|
|
537
|
+
const adjustedY = comp.y * (this.canvas.height / data.height) + layout.shiftY;
|
|
538
|
+
this.ctx.drawImage(this.tempCanvas, adjustedX, adjustedY, scaledWidth, scaledHeight);
|
|
362
539
|
}
|
|
540
|
+
this.ctx.restore();
|
|
363
541
|
}
|
|
364
542
|
/** Dispose of all resources. */
|
|
365
543
|
dispose() {
|
|
@@ -370,18 +548,33 @@ class BaseVideoSubtitleRenderer {
|
|
|
370
548
|
}
|
|
371
549
|
this.resizeObserver?.disconnect();
|
|
372
550
|
this.resizeObserver = null;
|
|
373
|
-
|
|
551
|
+
if (this.loadedMetadataHandler) {
|
|
552
|
+
this.video.removeEventListener('loadedmetadata', this.loadedMetadataHandler);
|
|
553
|
+
this.loadedMetadataHandler = null;
|
|
554
|
+
}
|
|
555
|
+
if (this.seekedHandler) {
|
|
556
|
+
this.video.removeEventListener('seeked', this.seekedHandler);
|
|
557
|
+
this.seekedHandler = null;
|
|
558
|
+
}
|
|
559
|
+
// Clean up GPU renderers
|
|
374
560
|
if (this.webgpuRenderer) {
|
|
375
561
|
this.webgpuRenderer.destroy();
|
|
376
562
|
this.webgpuRenderer = null;
|
|
377
563
|
}
|
|
564
|
+
if (this.webgl2Renderer) {
|
|
565
|
+
this.webgl2Renderer.destroy();
|
|
566
|
+
this.webgl2Renderer = null;
|
|
567
|
+
}
|
|
378
568
|
this.canvas?.parentElement?.removeChild(this.canvas);
|
|
379
569
|
this.canvas = null;
|
|
380
570
|
this.ctx = null;
|
|
381
571
|
this.tempCanvas = null;
|
|
382
572
|
this.tempCtx = null;
|
|
383
573
|
this.lastRenderedData = null;
|
|
574
|
+
this.currentCueMetadata = null;
|
|
575
|
+
this.parserMetadata = null;
|
|
384
576
|
this.useWebGPU = false;
|
|
577
|
+
this.useWebGL2 = false;
|
|
385
578
|
}
|
|
386
579
|
}
|
|
387
580
|
/**
|
|
@@ -395,14 +588,16 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
395
588
|
onLoaded;
|
|
396
589
|
onError;
|
|
397
590
|
constructor(options) {
|
|
398
|
-
super(options);
|
|
591
|
+
super(options, 'pgs');
|
|
399
592
|
this.onLoading = options.onLoading;
|
|
400
593
|
this.onLoaded = options.onLoaded;
|
|
401
594
|
this.onError = options.onError;
|
|
595
|
+
applyCacheLimit(this.state, this.cacheLimit);
|
|
402
596
|
this.startInit();
|
|
403
597
|
}
|
|
404
598
|
async loadSubtitles() {
|
|
405
599
|
try {
|
|
600
|
+
this.emitEvent({ type: 'loading', format: 'pgs' });
|
|
406
601
|
this.onLoading?.();
|
|
407
602
|
let arrayBuffer;
|
|
408
603
|
if (this.subContent) {
|
|
@@ -420,16 +615,24 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
420
615
|
const data = new Uint8Array(arrayBuffer);
|
|
421
616
|
if (this.state.useWorker) {
|
|
422
617
|
try {
|
|
618
|
+
this.state.sessionId = createWorkerSessionId();
|
|
423
619
|
await getOrCreateWorker();
|
|
424
|
-
|
|
620
|
+
this.emitWorkerState(true, false, this.state.sessionId);
|
|
621
|
+
const loadResponse = await sendToWorker({
|
|
622
|
+
type: 'loadPgs',
|
|
623
|
+
sessionId: this.state.sessionId,
|
|
624
|
+
data: data.buffer.slice(0)
|
|
625
|
+
});
|
|
425
626
|
if (loadResponse.type === 'pgsLoaded') {
|
|
426
627
|
this.state.workerReady = true;
|
|
427
|
-
|
|
628
|
+
this.state.metadata = loadResponse.metadata;
|
|
629
|
+
const tsResponse = await sendToWorker({ type: 'getPgsTimestamps', sessionId: this.state.sessionId });
|
|
428
630
|
if (tsResponse.type === 'pgsTimestamps') {
|
|
429
631
|
this.state.timestamps = tsResponse.timestamps;
|
|
430
632
|
}
|
|
431
633
|
this.isLoaded = true;
|
|
432
|
-
|
|
634
|
+
this.setParserMetadata(loadResponse.metadata);
|
|
635
|
+
this.emitWorkerState(true, true, this.state.sessionId);
|
|
433
636
|
this.onLoaded?.();
|
|
434
637
|
return; // Success, don't fall through to main thread
|
|
435
638
|
}
|
|
@@ -438,8 +641,8 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
438
641
|
}
|
|
439
642
|
}
|
|
440
643
|
catch (workerError) {
|
|
441
|
-
console.warn('[libbitsub] Worker failed, falling back to main thread:', workerError);
|
|
442
644
|
this.state.useWorker = false;
|
|
645
|
+
this.emitWorkerState(false, false, this.state.sessionId, true);
|
|
443
646
|
}
|
|
444
647
|
}
|
|
445
648
|
// Main thread fallback - use idle callback to avoid blocking UI
|
|
@@ -447,8 +650,9 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
447
650
|
this.onLoaded?.();
|
|
448
651
|
}
|
|
449
652
|
catch (error) {
|
|
450
|
-
|
|
451
|
-
this.
|
|
653
|
+
const resolvedError = error instanceof Error ? error : new Error(String(error));
|
|
654
|
+
this.emitEvent({ type: 'error', format: 'pgs', error: resolvedError });
|
|
655
|
+
this.onError?.(resolvedError);
|
|
452
656
|
}
|
|
453
657
|
}
|
|
454
658
|
async loadOnMainThread(data) {
|
|
@@ -464,8 +668,9 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
464
668
|
scheduleTask(() => {
|
|
465
669
|
const count = this.pgsParser.load(data);
|
|
466
670
|
this.state.timestamps = this.pgsParser.getTimestamps();
|
|
671
|
+
this.state.metadata = this.pgsParser.getMetadata();
|
|
467
672
|
this.isLoaded = true;
|
|
468
|
-
|
|
673
|
+
this.setParserMetadata(this.state.metadata);
|
|
469
674
|
resolve();
|
|
470
675
|
});
|
|
471
676
|
});
|
|
@@ -491,16 +696,22 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
491
696
|
return this.pgsParser?.findIndexAtTimestamp(time) ?? -1;
|
|
492
697
|
}
|
|
493
698
|
renderAtIndex(index) {
|
|
699
|
+
if (this.state.frameCache.has(index)) {
|
|
700
|
+
return this.state.frameCache.get(index) ?? undefined;
|
|
701
|
+
}
|
|
494
702
|
if (this.state.useWorker && this.state.workerReady) {
|
|
495
|
-
if (this.state.frameCache.has(index)) {
|
|
496
|
-
return this.state.frameCache.get(index) ?? undefined;
|
|
497
|
-
}
|
|
498
703
|
if (!this.state.pendingRenders.has(index)) {
|
|
499
|
-
const renderPromise = sendToWorker({
|
|
704
|
+
const renderPromise = sendToWorker({
|
|
705
|
+
type: 'renderPgsAtIndex',
|
|
706
|
+
sessionId: this.state.sessionId,
|
|
707
|
+
index
|
|
708
|
+
}).then((response) => (response.type === 'pgsFrame' && response.frame ? convertFrameData(response.frame) : null));
|
|
500
709
|
this.state.pendingRenders.set(index, renderPromise);
|
|
710
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
501
711
|
renderPromise.then((result) => {
|
|
502
|
-
this.state
|
|
712
|
+
setCachedFrame(this.state, index, result);
|
|
503
713
|
this.state.pendingRenders.delete(index);
|
|
714
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
504
715
|
// Force re-render on next frame by resetting lastRenderedIndex
|
|
505
716
|
if (this.findCurrentIndex(this.video.currentTime) === index) {
|
|
506
717
|
this.lastRenderedIndex = -1;
|
|
@@ -510,7 +721,32 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
510
721
|
// Return undefined to indicate async loading in progress
|
|
511
722
|
return undefined;
|
|
512
723
|
}
|
|
513
|
-
|
|
724
|
+
const rendered = this.pgsParser?.renderAtIndex(index) ?? null;
|
|
725
|
+
setCachedFrame(this.state, index, rendered);
|
|
726
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
727
|
+
return rendered ?? undefined;
|
|
728
|
+
}
|
|
729
|
+
buildCueMetadata(index) {
|
|
730
|
+
if (this.pgsParser) {
|
|
731
|
+
return this.pgsParser.getCueMetadata(index);
|
|
732
|
+
}
|
|
733
|
+
const metadata = this.state.metadata;
|
|
734
|
+
if (!metadata || index < 0 || index >= this.state.timestamps.length)
|
|
735
|
+
return null;
|
|
736
|
+
const startTime = this.state.timestamps[index];
|
|
737
|
+
const endTime = this.state.timestamps[index + 1] ?? startTime + 5000;
|
|
738
|
+
const frame = this.state.frameCache.get(index) ?? null;
|
|
739
|
+
return {
|
|
740
|
+
index,
|
|
741
|
+
format: 'pgs',
|
|
742
|
+
startTime,
|
|
743
|
+
endTime,
|
|
744
|
+
duration: Math.max(0, endTime - startTime),
|
|
745
|
+
screenWidth: metadata.screenWidth,
|
|
746
|
+
screenHeight: metadata.screenHeight,
|
|
747
|
+
bounds: frame ? getSubtitleBounds(frame) : null,
|
|
748
|
+
compositionCount: frame?.compositionData.length ?? 0
|
|
749
|
+
};
|
|
514
750
|
}
|
|
515
751
|
isPendingRender(index) {
|
|
516
752
|
return this.state.pendingRenders.has(index);
|
|
@@ -518,10 +754,43 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
518
754
|
onSeek() {
|
|
519
755
|
this.state.frameCache.clear();
|
|
520
756
|
this.state.pendingRenders.clear();
|
|
757
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
758
|
+
if (this.state.useWorker && this.state.workerReady) {
|
|
759
|
+
sendToWorker({ type: 'clearPgsCache', sessionId: this.state.sessionId }).catch(() => { });
|
|
760
|
+
}
|
|
761
|
+
this.pgsParser?.clearCache();
|
|
762
|
+
}
|
|
763
|
+
setCacheLimit(limit) {
|
|
764
|
+
this.cacheLimit = applyCacheLimit(this.state, limit);
|
|
765
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
766
|
+
}
|
|
767
|
+
clearFrameCache() {
|
|
768
|
+
this.state.frameCache.clear();
|
|
769
|
+
this.state.pendingRenders.clear();
|
|
770
|
+
this.lastRenderedIndex = -1;
|
|
521
771
|
if (this.state.useWorker && this.state.workerReady) {
|
|
522
|
-
sendToWorker({ type: 'clearPgsCache' }).catch(() => { });
|
|
772
|
+
sendToWorker({ type: 'clearPgsCache', sessionId: this.state.sessionId }).catch(() => { });
|
|
523
773
|
}
|
|
524
774
|
this.pgsParser?.clearCache();
|
|
775
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
776
|
+
}
|
|
777
|
+
async prefetchRange(startIndex, endIndex) {
|
|
778
|
+
const safeStart = Math.max(0, Math.min(startIndex, endIndex));
|
|
779
|
+
const safeEnd = Math.min(Math.max(startIndex, endIndex), this.state.timestamps.length - 1);
|
|
780
|
+
for (let index = safeStart; index <= safeEnd; index++) {
|
|
781
|
+
if (this.state.frameCache.has(index))
|
|
782
|
+
continue;
|
|
783
|
+
const result = this.renderAtIndex(index);
|
|
784
|
+
if (result === undefined && this.state.pendingRenders.has(index)) {
|
|
785
|
+
await this.state.pendingRenders.get(index);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
async prefetchAroundTime(time, before = this.prefetchBefore, after = this.prefetchAfter) {
|
|
790
|
+
const currentIndex = this.findCurrentIndex(time);
|
|
791
|
+
if (currentIndex < 0)
|
|
792
|
+
return;
|
|
793
|
+
await this.prefetchRange(currentIndex - before, currentIndex + after);
|
|
525
794
|
}
|
|
526
795
|
/** Get performance statistics for PGS renderer */
|
|
527
796
|
getStats() {
|
|
@@ -539,10 +808,11 @@ export class PgsRenderer extends BaseVideoSubtitleRenderer {
|
|
|
539
808
|
this.state.frameCache.clear();
|
|
540
809
|
this.state.pendingRenders.clear();
|
|
541
810
|
if (this.state.useWorker && this.state.workerReady) {
|
|
542
|
-
sendToWorker({ type: 'disposePgs' }).catch(() => { });
|
|
811
|
+
sendToWorker({ type: 'disposePgs', sessionId: this.state.sessionId }).catch(() => { });
|
|
543
812
|
}
|
|
544
813
|
this.pgsParser?.dispose();
|
|
545
814
|
this.pgsParser = null;
|
|
815
|
+
this.state.sessionId = null;
|
|
546
816
|
}
|
|
547
817
|
}
|
|
548
818
|
/**
|
|
@@ -562,18 +832,19 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
562
832
|
cachedIndexTime = -1;
|
|
563
833
|
pendingIndexLookup = null;
|
|
564
834
|
constructor(options) {
|
|
565
|
-
super(options);
|
|
835
|
+
super(options, 'vobsub');
|
|
566
836
|
this.idxUrl = options.idxUrl || (options.subUrl ? options.subUrl.replace(/\\.sub$/i, '.idx') : undefined);
|
|
567
837
|
this.idxContent = options.idxContent;
|
|
568
838
|
this.onLoading = options.onLoading;
|
|
569
839
|
this.onLoaded = options.onLoaded;
|
|
570
840
|
this.onError = options.onError;
|
|
841
|
+
applyCacheLimit(this.state, this.cacheLimit);
|
|
571
842
|
this.startInit();
|
|
572
843
|
}
|
|
573
844
|
async loadSubtitles() {
|
|
574
845
|
try {
|
|
846
|
+
this.emitEvent({ type: 'loading', format: 'vobsub' });
|
|
575
847
|
this.onLoading?.();
|
|
576
|
-
console.log(`[libbitsub] Loading VobSub`);
|
|
577
848
|
let subArrayBuffer;
|
|
578
849
|
let idxData;
|
|
579
850
|
// Resolve SUB content
|
|
@@ -619,23 +890,27 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
619
890
|
throw new Error('Failed to load VobSub data');
|
|
620
891
|
}
|
|
621
892
|
const subData = new Uint8Array(subArrayBuffer);
|
|
622
|
-
console.log(`[libbitsub] VobSub files loaded: .sub=${subArrayBuffer.byteLength} bytes, .idx=${idxData.length} chars`);
|
|
623
893
|
if (this.state.useWorker) {
|
|
624
894
|
try {
|
|
895
|
+
this.state.sessionId = createWorkerSessionId();
|
|
625
896
|
await getOrCreateWorker();
|
|
897
|
+
this.emitWorkerState(true, false, this.state.sessionId);
|
|
626
898
|
const loadResponse = await sendToWorker({
|
|
627
899
|
type: 'loadVobSub',
|
|
900
|
+
sessionId: this.state.sessionId,
|
|
628
901
|
idxContent: idxData,
|
|
629
902
|
subData: subData.buffer.slice(0)
|
|
630
903
|
});
|
|
631
904
|
if (loadResponse.type === 'vobSubLoaded') {
|
|
632
905
|
this.state.workerReady = true;
|
|
633
|
-
|
|
906
|
+
this.state.metadata = loadResponse.metadata;
|
|
907
|
+
const tsResponse = await sendToWorker({ type: 'getVobSubTimestamps', sessionId: this.state.sessionId });
|
|
634
908
|
if (tsResponse.type === 'vobSubTimestamps') {
|
|
635
909
|
this.state.timestamps = tsResponse.timestamps;
|
|
636
910
|
}
|
|
637
911
|
this.isLoaded = true;
|
|
638
|
-
|
|
912
|
+
this.setParserMetadata(loadResponse.metadata);
|
|
913
|
+
this.emitWorkerState(true, true, this.state.sessionId);
|
|
639
914
|
this.onLoaded?.();
|
|
640
915
|
return; // Success, don't fall through to main thread
|
|
641
916
|
}
|
|
@@ -644,8 +919,8 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
644
919
|
}
|
|
645
920
|
}
|
|
646
921
|
catch (workerError) {
|
|
647
|
-
console.warn('[libbitsub] Worker failed, falling back to main thread:', workerError);
|
|
648
922
|
this.state.useWorker = false;
|
|
923
|
+
this.emitWorkerState(false, false, this.state.sessionId, true);
|
|
649
924
|
}
|
|
650
925
|
}
|
|
651
926
|
// Main thread fallback
|
|
@@ -653,8 +928,9 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
653
928
|
this.onLoaded?.();
|
|
654
929
|
}
|
|
655
930
|
catch (error) {
|
|
656
|
-
|
|
657
|
-
this.
|
|
931
|
+
const resolvedError = error instanceof Error ? error : new Error(String(error));
|
|
932
|
+
this.emitEvent({ type: 'error', format: 'vobsub', error: resolvedError });
|
|
933
|
+
this.onError?.(resolvedError);
|
|
658
934
|
}
|
|
659
935
|
}
|
|
660
936
|
async loadOnMainThread(idxData, subData) {
|
|
@@ -669,8 +945,9 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
669
945
|
scheduleTask(() => {
|
|
670
946
|
this.vobsubParser.loadFromData(idxData, subData);
|
|
671
947
|
this.state.timestamps = this.vobsubParser.getTimestamps();
|
|
672
|
-
|
|
948
|
+
this.state.metadata = this.vobsubParser.getMetadata();
|
|
673
949
|
this.isLoaded = true;
|
|
950
|
+
this.setParserMetadata(this.state.metadata);
|
|
674
951
|
resolve();
|
|
675
952
|
});
|
|
676
953
|
});
|
|
@@ -698,7 +975,11 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
698
975
|
}
|
|
699
976
|
// Start async lookup if not already pending
|
|
700
977
|
if (!this.pendingIndexLookup) {
|
|
701
|
-
this.pendingIndexLookup = sendToWorker({
|
|
978
|
+
this.pendingIndexLookup = sendToWorker({
|
|
979
|
+
type: 'findVobSubIndex',
|
|
980
|
+
sessionId: this.state.sessionId,
|
|
981
|
+
timeMs
|
|
982
|
+
}).then((response) => {
|
|
702
983
|
if (response.type === 'vobSubIndex') {
|
|
703
984
|
const newIndex = response.index;
|
|
704
985
|
const oldIndex = this.cachedIndex;
|
|
@@ -718,18 +999,23 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
718
999
|
return this.vobsubParser?.findIndexAtTimestamp(time) ?? -1;
|
|
719
1000
|
}
|
|
720
1001
|
renderAtIndex(index) {
|
|
1002
|
+
if (this.state.frameCache.has(index)) {
|
|
1003
|
+
return this.state.frameCache.get(index) ?? undefined;
|
|
1004
|
+
}
|
|
721
1005
|
if (this.state.useWorker && this.state.workerReady) {
|
|
722
|
-
// Return cached frame immediately if available
|
|
723
|
-
if (this.state.frameCache.has(index)) {
|
|
724
|
-
return this.state.frameCache.get(index) ?? undefined;
|
|
725
|
-
}
|
|
726
1006
|
// Start async render if not already pending
|
|
727
1007
|
if (!this.state.pendingRenders.has(index)) {
|
|
728
|
-
const renderPromise = sendToWorker({
|
|
1008
|
+
const renderPromise = sendToWorker({
|
|
1009
|
+
type: 'renderVobSubAtIndex',
|
|
1010
|
+
sessionId: this.state.sessionId,
|
|
1011
|
+
index
|
|
1012
|
+
}).then((response) => (response.type === 'vobSubFrame' && response.frame ? convertFrameData(response.frame) : null));
|
|
729
1013
|
this.state.pendingRenders.set(index, renderPromise);
|
|
1014
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
730
1015
|
renderPromise.then((result) => {
|
|
731
|
-
this.state
|
|
1016
|
+
setCachedFrame(this.state, index, result);
|
|
732
1017
|
this.state.pendingRenders.delete(index);
|
|
1018
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
733
1019
|
// Force re-render on next frame by resetting lastRenderedIndex
|
|
734
1020
|
if (this.findCurrentIndex(this.video.currentTime) === index) {
|
|
735
1021
|
this.lastRenderedIndex = -1;
|
|
@@ -739,7 +1025,34 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
739
1025
|
// Return undefined to indicate async loading in progress
|
|
740
1026
|
return undefined;
|
|
741
1027
|
}
|
|
742
|
-
|
|
1028
|
+
const rendered = this.vobsubParser?.renderAtIndex(index) ?? null;
|
|
1029
|
+
setCachedFrame(this.state, index, rendered);
|
|
1030
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
1031
|
+
return rendered ?? undefined;
|
|
1032
|
+
}
|
|
1033
|
+
buildCueMetadata(index) {
|
|
1034
|
+
if (this.vobsubParser) {
|
|
1035
|
+
return this.vobsubParser.getCueMetadata(index);
|
|
1036
|
+
}
|
|
1037
|
+
const metadata = this.state.metadata;
|
|
1038
|
+
if (!metadata || index < 0 || index >= this.state.timestamps.length)
|
|
1039
|
+
return null;
|
|
1040
|
+
const startTime = this.state.timestamps[index];
|
|
1041
|
+
const endTime = this.state.timestamps[index + 1] ?? startTime + 5000;
|
|
1042
|
+
const frame = this.state.frameCache.get(index) ?? null;
|
|
1043
|
+
return {
|
|
1044
|
+
index,
|
|
1045
|
+
format: 'vobsub',
|
|
1046
|
+
startTime,
|
|
1047
|
+
endTime,
|
|
1048
|
+
duration: Math.max(0, endTime - startTime),
|
|
1049
|
+
screenWidth: metadata.screenWidth,
|
|
1050
|
+
screenHeight: metadata.screenHeight,
|
|
1051
|
+
bounds: frame ? getSubtitleBounds(frame) : null,
|
|
1052
|
+
compositionCount: frame?.compositionData.length ?? 0,
|
|
1053
|
+
language: metadata.language ?? null,
|
|
1054
|
+
trackId: metadata.trackId ?? null
|
|
1055
|
+
};
|
|
743
1056
|
}
|
|
744
1057
|
isPendingRender(index) {
|
|
745
1058
|
return this.state.pendingRenders.has(index);
|
|
@@ -752,9 +1065,45 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
752
1065
|
this.cachedIndexTime = -1;
|
|
753
1066
|
this.pendingIndexLookup = null;
|
|
754
1067
|
if (this.state.useWorker && this.state.workerReady) {
|
|
755
|
-
sendToWorker({ type: 'clearVobSubCache' }).catch(() => { });
|
|
1068
|
+
sendToWorker({ type: 'clearVobSubCache', sessionId: this.state.sessionId }).catch(() => { });
|
|
1069
|
+
}
|
|
1070
|
+
this.vobsubParser?.clearCache();
|
|
1071
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
1072
|
+
}
|
|
1073
|
+
setCacheLimit(limit) {
|
|
1074
|
+
this.cacheLimit = applyCacheLimit(this.state, limit);
|
|
1075
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
1076
|
+
}
|
|
1077
|
+
clearFrameCache() {
|
|
1078
|
+
this.state.frameCache.clear();
|
|
1079
|
+
this.state.pendingRenders.clear();
|
|
1080
|
+
this.cachedIndex = -1;
|
|
1081
|
+
this.cachedIndexTime = -1;
|
|
1082
|
+
this.pendingIndexLookup = null;
|
|
1083
|
+
this.lastRenderedIndex = -1;
|
|
1084
|
+
if (this.state.useWorker && this.state.workerReady) {
|
|
1085
|
+
sendToWorker({ type: 'clearVobSubCache', sessionId: this.state.sessionId }).catch(() => { });
|
|
756
1086
|
}
|
|
757
1087
|
this.vobsubParser?.clearCache();
|
|
1088
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
1089
|
+
}
|
|
1090
|
+
async prefetchRange(startIndex, endIndex) {
|
|
1091
|
+
const safeStart = Math.max(0, Math.min(startIndex, endIndex));
|
|
1092
|
+
const safeEnd = Math.min(Math.max(startIndex, endIndex), this.state.timestamps.length - 1);
|
|
1093
|
+
for (let index = safeStart; index <= safeEnd; index++) {
|
|
1094
|
+
if (this.state.frameCache.has(index))
|
|
1095
|
+
continue;
|
|
1096
|
+
const result = this.renderAtIndex(index);
|
|
1097
|
+
if (result === undefined && this.state.pendingRenders.has(index)) {
|
|
1098
|
+
await this.state.pendingRenders.get(index);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
async prefetchAroundTime(time, before = this.prefetchBefore, after = this.prefetchAfter) {
|
|
1103
|
+
const currentIndex = this.findCurrentIndex(time);
|
|
1104
|
+
if (currentIndex < 0)
|
|
1105
|
+
return;
|
|
1106
|
+
await this.prefetchRange(currentIndex - before, currentIndex + after);
|
|
758
1107
|
}
|
|
759
1108
|
/** Get performance statistics for VobSub renderer */
|
|
760
1109
|
getStats() {
|
|
@@ -770,32 +1119,35 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
770
1119
|
/** Enable or disable debanding filter */
|
|
771
1120
|
setDebandEnabled(enabled) {
|
|
772
1121
|
if (this.state.useWorker && this.state.workerReady) {
|
|
773
|
-
sendToWorker({ type: 'setVobSubDebandEnabled', enabled }).catch(() => { });
|
|
1122
|
+
sendToWorker({ type: 'setVobSubDebandEnabled', sessionId: this.state.sessionId, enabled }).catch(() => { });
|
|
774
1123
|
}
|
|
775
1124
|
this.vobsubParser?.setDebandEnabled(enabled);
|
|
776
1125
|
// Clear cache to force re-render with new settings
|
|
777
1126
|
this.state.frameCache.clear();
|
|
778
1127
|
this.lastRenderedIndex = -1;
|
|
1128
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
779
1129
|
}
|
|
780
1130
|
/** Set debanding threshold (0-255, default: 64) */
|
|
781
1131
|
setDebandThreshold(threshold) {
|
|
782
1132
|
if (this.state.useWorker && this.state.workerReady) {
|
|
783
|
-
sendToWorker({ type: 'setVobSubDebandThreshold', threshold }).catch(() => { });
|
|
1133
|
+
sendToWorker({ type: 'setVobSubDebandThreshold', sessionId: this.state.sessionId, threshold }).catch(() => { });
|
|
784
1134
|
}
|
|
785
1135
|
this.vobsubParser?.setDebandThreshold(threshold);
|
|
786
1136
|
// Clear cache to force re-render with new settings
|
|
787
1137
|
this.state.frameCache.clear();
|
|
788
1138
|
this.lastRenderedIndex = -1;
|
|
1139
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
789
1140
|
}
|
|
790
1141
|
/** Set debanding sample range in pixels (1-64, default: 15) */
|
|
791
1142
|
setDebandRange(range) {
|
|
792
1143
|
if (this.state.useWorker && this.state.workerReady) {
|
|
793
|
-
sendToWorker({ type: 'setVobSubDebandRange', range }).catch(() => { });
|
|
1144
|
+
sendToWorker({ type: 'setVobSubDebandRange', sessionId: this.state.sessionId, range }).catch(() => { });
|
|
794
1145
|
}
|
|
795
1146
|
this.vobsubParser?.setDebandRange(range);
|
|
796
1147
|
// Clear cache to force re-render with new settings
|
|
797
1148
|
this.state.frameCache.clear();
|
|
798
1149
|
this.lastRenderedIndex = -1;
|
|
1150
|
+
this.emitCacheChange(this.state.frameCache.size, this.state.pendingRenders.size);
|
|
799
1151
|
}
|
|
800
1152
|
/** Check if debanding is enabled */
|
|
801
1153
|
get debandEnabled() {
|
|
@@ -806,10 +1158,28 @@ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
|
|
|
806
1158
|
this.state.frameCache.clear();
|
|
807
1159
|
this.state.pendingRenders.clear();
|
|
808
1160
|
if (this.state.useWorker && this.state.workerReady) {
|
|
809
|
-
sendToWorker({ type: 'disposeVobSub' }).catch(() => { });
|
|
1161
|
+
sendToWorker({ type: 'disposeVobSub', sessionId: this.state.sessionId }).catch(() => { });
|
|
810
1162
|
}
|
|
811
1163
|
this.vobsubParser?.dispose();
|
|
812
1164
|
this.vobsubParser = null;
|
|
1165
|
+
this.state.sessionId = null;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
/** Create a video subtitle renderer with automatic format detection. */
|
|
1169
|
+
export function createAutoSubtitleRenderer(options) {
|
|
1170
|
+
const format = detectSubtitleFormat({
|
|
1171
|
+
data: options.subContent,
|
|
1172
|
+
idxContent: options.idxContent,
|
|
1173
|
+
fileName: options.fileName,
|
|
1174
|
+
subUrl: options.subUrl,
|
|
1175
|
+
idxUrl: options.idxUrl
|
|
1176
|
+
});
|
|
1177
|
+
if (format === 'pgs') {
|
|
1178
|
+
return new PgsRenderer(options);
|
|
1179
|
+
}
|
|
1180
|
+
if (format === 'vobsub') {
|
|
1181
|
+
return new VobSubRenderer(options);
|
|
813
1182
|
}
|
|
1183
|
+
throw new Error('Unable to detect subtitle format for video renderer');
|
|
814
1184
|
}
|
|
815
1185
|
//# sourceMappingURL=renderers.js.map
|