libbitsub 1.0.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.
@@ -0,0 +1,640 @@
1
+ /**
2
+ * High-level video-integrated subtitle renderers for libbitsub.
3
+ * Handles canvas overlay, video sync, and subtitle fetching.
4
+ */
5
+ import { initWasm } from './wasm';
6
+ import { getOrCreateWorker, sendToWorker } from './worker';
7
+ import { binarySearchTimestamp, convertFrameData, createWorkerState } from './utils';
8
+ import { PgsParser, VobSubParserLowLevel } from './parsers';
9
+ /** Default display settings */
10
+ const DEFAULT_DISPLAY_SETTINGS = {
11
+ scale: 1.0,
12
+ verticalOffset: 0
13
+ };
14
+ /**
15
+ * Base class for video-integrated subtitle renderers.
16
+ * Handles canvas overlay, video sync, and subtitle fetching.
17
+ */
18
+ class BaseVideoSubtitleRenderer {
19
+ video;
20
+ subUrl;
21
+ canvas = null;
22
+ ctx = null;
23
+ animationFrameId = null;
24
+ isLoaded = false;
25
+ lastRenderedIndex = -1;
26
+ lastRenderedTime = -1;
27
+ disposed = false;
28
+ resizeObserver = null;
29
+ tempCanvas = null;
30
+ tempCtx = null;
31
+ lastRenderedData = null;
32
+ /** Display settings for subtitle rendering */
33
+ displaySettings = { ...DEFAULT_DISPLAY_SETTINGS };
34
+ // Performance tracking
35
+ perfStats = {
36
+ framesRendered: 0,
37
+ framesDropped: 0,
38
+ renderTimes: [],
39
+ lastRenderTime: 0,
40
+ fpsTimestamps: [],
41
+ lastFrameTime: 0
42
+ };
43
+ constructor(options) {
44
+ this.video = options.video;
45
+ this.subUrl = options.subUrl;
46
+ }
47
+ /** Get current display settings */
48
+ getDisplaySettings() {
49
+ return { ...this.displaySettings };
50
+ }
51
+ /** Get base stats common to all renderers */
52
+ getBaseStats() {
53
+ const now = performance.now();
54
+ // Clean up old FPS timestamps (keep last second)
55
+ this.perfStats.fpsTimestamps = this.perfStats.fpsTimestamps.filter((t) => now - t < 1000);
56
+ const renderTimes = this.perfStats.renderTimes;
57
+ const avgRenderTime = renderTimes.length > 0 ? renderTimes.reduce((a, b) => a + b, 0) / renderTimes.length : 0;
58
+ const maxRenderTime = renderTimes.length > 0 ? Math.max(...renderTimes) : 0;
59
+ const minRenderTime = renderTimes.length > 0 ? Math.min(...renderTimes) : 0;
60
+ return {
61
+ framesRendered: this.perfStats.framesRendered,
62
+ framesDropped: this.perfStats.framesDropped,
63
+ avgRenderTime: Math.round(avgRenderTime * 100) / 100,
64
+ maxRenderTime: Math.round(maxRenderTime * 100) / 100,
65
+ minRenderTime: Math.round(minRenderTime * 100) / 100,
66
+ lastRenderTime: Math.round(this.perfStats.lastRenderTime * 100) / 100,
67
+ renderFps: this.perfStats.fpsTimestamps.length,
68
+ currentIndex: this.lastRenderedIndex
69
+ };
70
+ }
71
+ /** Set display settings and force re-render */
72
+ setDisplaySettings(settings) {
73
+ const changed = settings.scale !== this.displaySettings.scale || settings.verticalOffset !== this.displaySettings.verticalOffset;
74
+ if (settings.scale !== undefined) {
75
+ this.displaySettings.scale = Math.max(0.1, Math.min(3.0, settings.scale));
76
+ }
77
+ if (settings.verticalOffset !== undefined) {
78
+ this.displaySettings.verticalOffset = Math.max(-50, Math.min(50, settings.verticalOffset));
79
+ }
80
+ // Force re-render if settings changed
81
+ if (changed) {
82
+ this.lastRenderedIndex = -1;
83
+ this.lastRenderedTime = -1;
84
+ }
85
+ }
86
+ /** Reset display settings to defaults */
87
+ resetDisplaySettings() {
88
+ this.displaySettings = { ...DEFAULT_DISPLAY_SETTINGS };
89
+ this.lastRenderedIndex = -1;
90
+ this.lastRenderedTime = -1;
91
+ }
92
+ /** Start initialization. */
93
+ startInit() {
94
+ this.init();
95
+ }
96
+ /** Initialize the renderer. */
97
+ async init() {
98
+ await initWasm();
99
+ this.createCanvas();
100
+ await new Promise((resolve) => setTimeout(resolve, 0));
101
+ await this.loadSubtitles();
102
+ this.startRenderLoop();
103
+ }
104
+ /** Create the canvas overlay positioned over the video. */
105
+ createCanvas() {
106
+ this.canvas = document.createElement('canvas');
107
+ Object.assign(this.canvas.style, {
108
+ position: 'absolute',
109
+ pointerEvents: 'none',
110
+ zIndex: '10'
111
+ });
112
+ const parent = this.video.parentElement;
113
+ if (parent) {
114
+ if (window.getComputedStyle(parent).position === 'static') {
115
+ parent.style.position = 'relative';
116
+ }
117
+ parent.appendChild(this.canvas);
118
+ }
119
+ this.ctx = this.canvas.getContext('2d');
120
+ this.updateCanvasSize();
121
+ this.resizeObserver = new ResizeObserver(() => this.updateCanvasSize());
122
+ this.resizeObserver.observe(this.video);
123
+ this.video.addEventListener('loadedmetadata', () => this.updateCanvasSize());
124
+ this.video.addEventListener('seeked', () => {
125
+ this.lastRenderedIndex = -1;
126
+ this.lastRenderedTime = -1;
127
+ this.onSeek();
128
+ });
129
+ }
130
+ /** Called when video seeks. */
131
+ onSeek() { }
132
+ /** Calculate the actual video content bounds, accounting for letterboxing/pillarboxing */
133
+ getVideoContentBounds() {
134
+ const rect = this.video.getBoundingClientRect();
135
+ const videoWidth = this.video.videoWidth || rect.width;
136
+ const videoHeight = this.video.videoHeight || rect.height;
137
+ // Calculate aspect ratios
138
+ const elementAspect = rect.width / rect.height;
139
+ const videoAspect = videoWidth / videoHeight;
140
+ let contentWidth;
141
+ let contentHeight;
142
+ let contentX;
143
+ let contentY;
144
+ if (Math.abs(elementAspect - videoAspect) < 0.01) {
145
+ // Aspect ratios match - video fills the element
146
+ contentWidth = rect.width;
147
+ contentHeight = rect.height;
148
+ contentX = 0;
149
+ contentY = 0;
150
+ }
151
+ else if (elementAspect > videoAspect) {
152
+ // Element is wider than video - pillarboxing (black bars on sides)
153
+ contentHeight = rect.height;
154
+ contentWidth = rect.height * videoAspect;
155
+ contentX = (rect.width - contentWidth) / 2;
156
+ contentY = 0;
157
+ }
158
+ else {
159
+ // Element is taller than video - letterboxing (black bars top/bottom)
160
+ contentWidth = rect.width;
161
+ contentHeight = rect.width / videoAspect;
162
+ contentX = 0;
163
+ contentY = (rect.height - contentHeight) / 2;
164
+ }
165
+ return { x: contentX, y: contentY, width: contentWidth, height: contentHeight };
166
+ }
167
+ /** Update canvas size to match video content area. */
168
+ updateCanvasSize() {
169
+ if (!this.canvas)
170
+ return;
171
+ const bounds = this.getVideoContentBounds();
172
+ const width = bounds.width > 0 ? bounds.width : this.video.videoWidth || 1920;
173
+ const height = bounds.height > 0 ? bounds.height : this.video.videoHeight || 1080;
174
+ this.canvas.width = width * window.devicePixelRatio;
175
+ this.canvas.height = height * window.devicePixelRatio;
176
+ // Position canvas to match video content area
177
+ this.canvas.style.left = `${bounds.x}px`;
178
+ this.canvas.style.top = `${bounds.y}px`;
179
+ this.canvas.style.width = `${bounds.width}px`;
180
+ this.canvas.style.height = `${bounds.height}px`;
181
+ this.lastRenderedIndex = -1;
182
+ this.lastRenderedTime = -1;
183
+ }
184
+ /** Start the render loop. */
185
+ startRenderLoop() {
186
+ // Create reusable temp canvas for rendering
187
+ this.tempCanvas = document.createElement('canvas');
188
+ this.tempCtx = this.tempCanvas.getContext('2d');
189
+ const render = () => {
190
+ if (this.disposed)
191
+ return;
192
+ if (this.isLoaded) {
193
+ const currentTime = this.video.currentTime;
194
+ const currentIndex = this.findCurrentIndex(currentTime);
195
+ // Only re-render if index changed
196
+ if (currentIndex !== this.lastRenderedIndex) {
197
+ const startTime = performance.now();
198
+ this.renderFrame(currentTime, currentIndex);
199
+ const endTime = performance.now();
200
+ // Track performance
201
+ const renderTime = endTime - startTime;
202
+ this.perfStats.lastRenderTime = renderTime;
203
+ this.perfStats.renderTimes.push(renderTime);
204
+ // Keep only last 60 samples for rolling average
205
+ if (this.perfStats.renderTimes.length > 60) {
206
+ this.perfStats.renderTimes.shift();
207
+ }
208
+ this.perfStats.framesRendered++;
209
+ this.perfStats.fpsTimestamps.push(endTime);
210
+ // Check for frame drop (if render took longer than frame budget ~16.67ms for 60fps)
211
+ const frameBudget = 16.67;
212
+ if (renderTime > frameBudget) {
213
+ this.perfStats.framesDropped++;
214
+ }
215
+ this.lastRenderedIndex = currentIndex;
216
+ this.lastRenderedTime = currentTime;
217
+ }
218
+ }
219
+ this.animationFrameId = requestAnimationFrame(render);
220
+ };
221
+ this.animationFrameId = requestAnimationFrame(render);
222
+ }
223
+ /** Render a subtitle frame to the canvas. */
224
+ renderFrame(time, index) {
225
+ if (!this.ctx || !this.canvas)
226
+ return;
227
+ // Get the data for this index
228
+ const data = index >= 0 ? this.renderAtIndex(index) : undefined;
229
+ // If data is undefined, it means async loading is in progress
230
+ // Keep showing the last frame only while waiting for async data
231
+ // Note: null means "loaded but empty" (clear screen), undefined means "still loading"
232
+ if (data === undefined && this.lastRenderedData !== null && index >= 0) {
233
+ // Check if this index has a pending render (truly async loading)
234
+ // If not pending, it means the render returned no data immediately
235
+ if (this.isPendingRender(index)) {
236
+ // Don't clear - keep showing the last frame while loading
237
+ return;
238
+ }
239
+ }
240
+ // Clear canvas
241
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
242
+ // If no subtitle at this index, we're done
243
+ if (index < 0 || !data || data.compositionData.length === 0) {
244
+ this.lastRenderedData = null;
245
+ return;
246
+ }
247
+ // Store for potential reuse
248
+ this.lastRenderedData = data;
249
+ // Calculate base scale factors
250
+ const baseScaleX = this.canvas.width / data.width;
251
+ const baseScaleY = this.canvas.height / data.height;
252
+ // Apply display settings
253
+ const { scale, verticalOffset } = this.displaySettings;
254
+ const scaleX = baseScaleX * scale;
255
+ const scaleY = baseScaleY * scale;
256
+ const offsetY = (verticalOffset / 100) * this.canvas.height;
257
+ for (const comp of data.compositionData) {
258
+ if (!this.tempCanvas || !this.tempCtx)
259
+ continue;
260
+ // Resize temp canvas if needed
261
+ if (this.tempCanvas.width !== comp.pixelData.width || this.tempCanvas.height !== comp.pixelData.height) {
262
+ this.tempCanvas.width = comp.pixelData.width;
263
+ this.tempCanvas.height = comp.pixelData.height;
264
+ }
265
+ this.tempCtx.putImageData(comp.pixelData, 0, 0);
266
+ // Calculate position with scale and offset applied
267
+ // Center the scaled content horizontally
268
+ const scaledWidth = comp.pixelData.width * scaleX;
269
+ const scaledHeight = comp.pixelData.height * scaleY;
270
+ const baseX = comp.x * baseScaleX;
271
+ const baseY = comp.y * baseScaleY;
272
+ const centeredX = baseX + (comp.pixelData.width * baseScaleX - scaledWidth) / 2;
273
+ const adjustedY = baseY + offsetY + (comp.pixelData.height * baseScaleY - scaledHeight);
274
+ this.ctx.drawImage(this.tempCanvas, centeredX, adjustedY, scaledWidth, scaledHeight);
275
+ }
276
+ }
277
+ /** Dispose of all resources. */
278
+ dispose() {
279
+ this.disposed = true;
280
+ if (this.animationFrameId !== null) {
281
+ cancelAnimationFrame(this.animationFrameId);
282
+ this.animationFrameId = null;
283
+ }
284
+ this.resizeObserver?.disconnect();
285
+ this.resizeObserver = null;
286
+ this.canvas?.parentElement?.removeChild(this.canvas);
287
+ this.canvas = null;
288
+ this.ctx = null;
289
+ this.tempCanvas = null;
290
+ this.tempCtx = null;
291
+ this.lastRenderedData = null;
292
+ }
293
+ }
294
+ /**
295
+ * High-level PGS subtitle renderer with Web Worker support.
296
+ * Compatible with the old libpgs-js API.
297
+ */
298
+ export class PgsRenderer extends BaseVideoSubtitleRenderer {
299
+ pgsParser = null;
300
+ state = createWorkerState();
301
+ onLoading;
302
+ onLoaded;
303
+ onError;
304
+ constructor(options) {
305
+ super(options);
306
+ this.onLoading = options.onLoading;
307
+ this.onLoaded = options.onLoaded;
308
+ this.onError = options.onError;
309
+ this.startInit();
310
+ }
311
+ async loadSubtitles() {
312
+ try {
313
+ this.onLoading?.();
314
+ const response = await fetch(this.subUrl);
315
+ if (!response.ok)
316
+ throw new Error(`Failed to fetch subtitle: ${response.status}`);
317
+ const arrayBuffer = await response.arrayBuffer();
318
+ const data = new Uint8Array(arrayBuffer);
319
+ if (this.state.useWorker) {
320
+ try {
321
+ await getOrCreateWorker();
322
+ const loadResponse = await sendToWorker({ type: 'loadPgs', data: data.buffer.slice(0) });
323
+ if (loadResponse.type === 'pgsLoaded') {
324
+ this.state.workerReady = true;
325
+ const tsResponse = await sendToWorker({ type: 'getPgsTimestamps' });
326
+ if (tsResponse.type === 'pgsTimestamps') {
327
+ this.state.timestamps = tsResponse.timestamps;
328
+ }
329
+ this.isLoaded = true;
330
+ console.log(`[libbitsub] PGS loaded (worker): ${loadResponse.count} display sets from ${loadResponse.byteLength} bytes`);
331
+ this.onLoaded?.();
332
+ return; // Success, don't fall through to main thread
333
+ }
334
+ else if (loadResponse.type === 'error') {
335
+ throw new Error(loadResponse.message);
336
+ }
337
+ }
338
+ catch (workerError) {
339
+ console.warn('[libbitsub] Worker failed, falling back to main thread:', workerError);
340
+ this.state.useWorker = false;
341
+ }
342
+ }
343
+ // Main thread fallback - use idle callback to avoid blocking UI
344
+ await this.loadOnMainThread(data);
345
+ this.onLoaded?.();
346
+ }
347
+ catch (error) {
348
+ console.error('Failed to load PGS subtitles:', error);
349
+ this.onError?.(error instanceof Error ? error : new Error(String(error)));
350
+ }
351
+ }
352
+ async loadOnMainThread(data) {
353
+ // Yield to browser before heavy parsing
354
+ await this.yieldToMain();
355
+ this.pgsParser = new PgsParser();
356
+ // Parse in a microtask to allow UI to update
357
+ await new Promise((resolve) => {
358
+ // Use requestIdleCallback if available, otherwise setTimeout
359
+ const scheduleTask = typeof requestIdleCallback !== 'undefined'
360
+ ? (cb) => requestIdleCallback(() => cb(), { timeout: 1000 })
361
+ : (cb) => setTimeout(cb, 0);
362
+ scheduleTask(() => {
363
+ const count = this.pgsParser.load(data);
364
+ this.state.timestamps = this.pgsParser.getTimestamps();
365
+ this.isLoaded = true;
366
+ console.log(`[libbitsub] PGS loaded (main thread): ${count} display sets from ${data.byteLength} bytes`);
367
+ resolve();
368
+ });
369
+ });
370
+ }
371
+ /** Yield to main thread to prevent UI blocking */
372
+ yieldToMain() {
373
+ // Use scheduler.yield if available (Chrome 115+)
374
+ const globalScheduler = globalThis.scheduler;
375
+ if (globalScheduler && typeof globalScheduler.yield === 'function') {
376
+ return globalScheduler.yield();
377
+ }
378
+ // Fallback to setTimeout
379
+ return new Promise((resolve) => setTimeout(resolve, 0));
380
+ }
381
+ renderAtTime(time) {
382
+ const index = this.findCurrentIndex(time);
383
+ return index < 0 ? undefined : this.renderAtIndex(index);
384
+ }
385
+ findCurrentIndex(time) {
386
+ if (this.state.useWorker && this.state.workerReady) {
387
+ return binarySearchTimestamp(this.state.timestamps, time * 1000);
388
+ }
389
+ return this.pgsParser?.findIndexAtTimestamp(time) ?? -1;
390
+ }
391
+ renderAtIndex(index) {
392
+ if (this.state.useWorker && this.state.workerReady) {
393
+ if (this.state.frameCache.has(index)) {
394
+ return this.state.frameCache.get(index) ?? undefined;
395
+ }
396
+ if (!this.state.pendingRenders.has(index)) {
397
+ const renderPromise = sendToWorker({ type: 'renderPgsAtIndex', index }).then((response) => response.type === 'pgsFrame' && response.frame ? convertFrameData(response.frame) : null);
398
+ this.state.pendingRenders.set(index, renderPromise);
399
+ renderPromise.then((result) => {
400
+ this.state.frameCache.set(index, result);
401
+ this.state.pendingRenders.delete(index);
402
+ // Force re-render on next frame by resetting lastRenderedIndex
403
+ if (this.findCurrentIndex(this.video.currentTime) === index) {
404
+ this.lastRenderedIndex = -1;
405
+ }
406
+ });
407
+ }
408
+ // Return undefined to indicate async loading in progress
409
+ return undefined;
410
+ }
411
+ return this.pgsParser?.renderAtIndex(index);
412
+ }
413
+ isPendingRender(index) {
414
+ return this.state.pendingRenders.has(index);
415
+ }
416
+ onSeek() {
417
+ this.state.frameCache.clear();
418
+ this.state.pendingRenders.clear();
419
+ if (this.state.useWorker && this.state.workerReady) {
420
+ sendToWorker({ type: 'clearPgsCache' }).catch(() => { });
421
+ }
422
+ this.pgsParser?.clearCache();
423
+ }
424
+ /** Get performance statistics for PGS renderer */
425
+ getStats() {
426
+ const baseStats = this.getBaseStats();
427
+ return {
428
+ ...baseStats,
429
+ usingWorker: this.state.useWorker && this.state.workerReady,
430
+ cachedFrames: this.state.frameCache.size,
431
+ pendingRenders: this.state.pendingRenders.size,
432
+ totalEntries: this.state.timestamps.length || (this.pgsParser?.getTimestamps().length ?? 0)
433
+ };
434
+ }
435
+ dispose() {
436
+ super.dispose();
437
+ this.state.frameCache.clear();
438
+ this.state.pendingRenders.clear();
439
+ if (this.state.useWorker && this.state.workerReady) {
440
+ sendToWorker({ type: 'disposePgs' }).catch(() => { });
441
+ }
442
+ this.pgsParser?.dispose();
443
+ this.pgsParser = null;
444
+ }
445
+ }
446
+ /**
447
+ * High-level VobSub subtitle renderer with Web Worker support.
448
+ * Compatible with the old libpgs-js API.
449
+ */
450
+ export class VobSubRenderer extends BaseVideoSubtitleRenderer {
451
+ vobsubParser = null;
452
+ idxUrl;
453
+ state = createWorkerState();
454
+ onLoading;
455
+ onLoaded;
456
+ onError;
457
+ // Async index lookup state
458
+ cachedIndex = -1;
459
+ cachedIndexTime = -1;
460
+ pendingIndexLookup = null;
461
+ constructor(options) {
462
+ super(options);
463
+ this.idxUrl = options.idxUrl || options.subUrl.replace(/\\.sub$/i, '.idx');
464
+ this.onLoading = options.onLoading;
465
+ this.onLoaded = options.onLoaded;
466
+ this.onError = options.onError;
467
+ this.startInit();
468
+ }
469
+ async loadSubtitles() {
470
+ try {
471
+ this.onLoading?.();
472
+ console.log(`[libbitsub] Loading VobSub: ${this.subUrl}, ${this.idxUrl}`);
473
+ const [subResponse, idxResponse] = await Promise.all([fetch(this.subUrl), fetch(this.idxUrl)]);
474
+ if (!subResponse.ok)
475
+ throw new Error(`Failed to fetch .sub file: ${subResponse.status}`);
476
+ if (!idxResponse.ok)
477
+ throw new Error(`Failed to fetch .idx file: ${idxResponse.status}`);
478
+ const subArrayBuffer = await subResponse.arrayBuffer();
479
+ const idxData = await idxResponse.text();
480
+ const subData = new Uint8Array(subArrayBuffer);
481
+ console.log(`[libbitsub] VobSub files loaded: .sub=${subArrayBuffer.byteLength} bytes, .idx=${idxData.length} chars`);
482
+ if (this.state.useWorker) {
483
+ try {
484
+ await getOrCreateWorker();
485
+ const loadResponse = await sendToWorker({
486
+ type: 'loadVobSub',
487
+ idxContent: idxData,
488
+ subData: subData.buffer.slice(0)
489
+ });
490
+ if (loadResponse.type === 'vobSubLoaded') {
491
+ this.state.workerReady = true;
492
+ const tsResponse = await sendToWorker({ type: 'getVobSubTimestamps' });
493
+ if (tsResponse.type === 'vobSubTimestamps') {
494
+ this.state.timestamps = tsResponse.timestamps;
495
+ }
496
+ this.isLoaded = true;
497
+ console.log(`[libbitsub] VobSub loaded (worker): ${loadResponse.count} subtitle entries`);
498
+ this.onLoaded?.();
499
+ return; // Success, don't fall through to main thread
500
+ }
501
+ else if (loadResponse.type === 'error') {
502
+ throw new Error(loadResponse.message);
503
+ }
504
+ }
505
+ catch (workerError) {
506
+ console.warn('[libbitsub] Worker failed, falling back to main thread:', workerError);
507
+ this.state.useWorker = false;
508
+ }
509
+ }
510
+ // Main thread fallback
511
+ await this.loadOnMainThread(idxData, subData);
512
+ this.onLoaded?.();
513
+ }
514
+ catch (error) {
515
+ console.error('Failed to load VobSub subtitles:', error);
516
+ this.onError?.(error instanceof Error ? error : new Error(String(error)));
517
+ }
518
+ }
519
+ async loadOnMainThread(idxData, subData) {
520
+ // Yield to browser before heavy parsing
521
+ await this.yieldToMain();
522
+ this.vobsubParser = new VobSubParserLowLevel();
523
+ // Parse in a microtask to allow UI to update
524
+ await new Promise((resolve) => {
525
+ const scheduleTask = typeof requestIdleCallback !== 'undefined'
526
+ ? (cb) => requestIdleCallback(() => cb(), { timeout: 1000 })
527
+ : (cb) => setTimeout(cb, 0);
528
+ scheduleTask(() => {
529
+ this.vobsubParser.loadFromData(idxData, subData);
530
+ this.state.timestamps = this.vobsubParser.getTimestamps();
531
+ console.log(`[libbitsub] VobSub loaded (main thread): ${this.vobsubParser.count} subtitle entries`);
532
+ this.isLoaded = true;
533
+ resolve();
534
+ });
535
+ });
536
+ }
537
+ /** Yield to main thread to prevent UI blocking */
538
+ yieldToMain() {
539
+ const globalScheduler = globalThis.scheduler;
540
+ if (globalScheduler && typeof globalScheduler.yield === 'function') {
541
+ return globalScheduler.yield();
542
+ }
543
+ return new Promise((resolve) => setTimeout(resolve, 0));
544
+ }
545
+ renderAtTime(time) {
546
+ const index = this.findCurrentIndex(time);
547
+ return index < 0 ? undefined : this.renderAtIndex(index);
548
+ }
549
+ findCurrentIndex(time) {
550
+ if (this.state.useWorker && this.state.workerReady) {
551
+ const timeMs = time * 1000;
552
+ // Only use cache if time is very close (within 1 frame)
553
+ const timeDelta = timeMs - this.cachedIndexTime;
554
+ const cacheValid = this.cachedIndexTime >= 0 && Math.abs(timeDelta) < 17;
555
+ if (cacheValid) {
556
+ return this.cachedIndex;
557
+ }
558
+ // Start async lookup if not already pending
559
+ if (!this.pendingIndexLookup) {
560
+ this.pendingIndexLookup = sendToWorker({ type: 'findVobSubIndex', timeMs }).then((response) => {
561
+ if (response.type === 'vobSubIndex') {
562
+ const newIndex = response.index;
563
+ const oldIndex = this.cachedIndex;
564
+ this.cachedIndex = newIndex;
565
+ this.cachedIndexTime = timeMs;
566
+ // Force re-render if index changed (including to -1 for clear)
567
+ if (oldIndex !== newIndex) {
568
+ this.lastRenderedIndex = -2; // Use -2 to force update even when new index is -1
569
+ }
570
+ }
571
+ this.pendingIndexLookup = null;
572
+ return this.cachedIndex;
573
+ });
574
+ }
575
+ return this.cachedIndex;
576
+ }
577
+ return this.vobsubParser?.findIndexAtTimestamp(time) ?? -1;
578
+ }
579
+ renderAtIndex(index) {
580
+ if (this.state.useWorker && this.state.workerReady) {
581
+ // Return cached frame immediately if available
582
+ if (this.state.frameCache.has(index)) {
583
+ return this.state.frameCache.get(index) ?? undefined;
584
+ }
585
+ // Start async render if not already pending
586
+ if (!this.state.pendingRenders.has(index)) {
587
+ const renderPromise = sendToWorker({ type: 'renderVobSubAtIndex', index }).then((response) => response.type === 'vobSubFrame' && response.frame ? convertFrameData(response.frame) : null);
588
+ this.state.pendingRenders.set(index, renderPromise);
589
+ renderPromise.then((result) => {
590
+ this.state.frameCache.set(index, result);
591
+ this.state.pendingRenders.delete(index);
592
+ // Force re-render on next frame by resetting lastRenderedIndex
593
+ if (this.findCurrentIndex(this.video.currentTime) === index) {
594
+ this.lastRenderedIndex = -1;
595
+ }
596
+ });
597
+ }
598
+ // Return undefined to indicate async loading in progress
599
+ return undefined;
600
+ }
601
+ return this.vobsubParser?.renderAtIndex(index);
602
+ }
603
+ isPendingRender(index) {
604
+ return this.state.pendingRenders.has(index);
605
+ }
606
+ onSeek() {
607
+ this.state.frameCache.clear();
608
+ this.state.pendingRenders.clear();
609
+ // Clear cached index lookup on seek
610
+ this.cachedIndex = -1;
611
+ this.cachedIndexTime = -1;
612
+ this.pendingIndexLookup = null;
613
+ if (this.state.useWorker && this.state.workerReady) {
614
+ sendToWorker({ type: 'clearVobSubCache' }).catch(() => { });
615
+ }
616
+ this.vobsubParser?.clearCache();
617
+ }
618
+ /** Get performance statistics for VobSub renderer */
619
+ getStats() {
620
+ const baseStats = this.getBaseStats();
621
+ return {
622
+ ...baseStats,
623
+ usingWorker: this.state.useWorker && this.state.workerReady,
624
+ cachedFrames: this.state.frameCache.size,
625
+ pendingRenders: this.state.pendingRenders.size,
626
+ totalEntries: this.state.timestamps.length || (this.vobsubParser?.getTimestamps().length ?? 0)
627
+ };
628
+ }
629
+ dispose() {
630
+ super.dispose();
631
+ this.state.frameCache.clear();
632
+ this.state.pendingRenders.clear();
633
+ if (this.state.useWorker && this.state.workerReady) {
634
+ sendToWorker({ type: 'disposeVobSub' }).catch(() => { });
635
+ }
636
+ this.vobsubParser?.dispose();
637
+ this.vobsubParser = null;
638
+ }
639
+ }
640
+ //# sourceMappingURL=renderers.js.map