@mediafox/core 1.2.9 → 1.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/compositor/source-pool.d.ts.map +1 -1
  2. package/dist/compositor-worker.js +1 -227
  3. package/dist/index.js +3 -3
  4. package/dist/playback/renderers/webgpu.d.ts.map +1 -1
  5. package/package.json +5 -4
  6. package/src/compositor/audio-manager.ts +411 -0
  7. package/src/compositor/compositor-worker.ts +158 -0
  8. package/src/compositor/compositor.ts +931 -0
  9. package/src/compositor/index.ts +19 -0
  10. package/src/compositor/source-pool.ts +489 -0
  11. package/src/compositor/types.ts +103 -0
  12. package/src/compositor/worker-client.ts +139 -0
  13. package/src/compositor/worker-types.ts +67 -0
  14. package/src/core/player-core.ts +273 -0
  15. package/src/core/state-facade.ts +98 -0
  16. package/src/core/track-switcher.ts +127 -0
  17. package/src/events/emitter.ts +137 -0
  18. package/src/events/types.ts +24 -0
  19. package/src/index.ts +124 -0
  20. package/src/mediafox.ts +642 -0
  21. package/src/playback/audio.ts +361 -0
  22. package/src/playback/controller.ts +446 -0
  23. package/src/playback/renderer.ts +1176 -0
  24. package/src/playback/renderers/canvas2d.ts +128 -0
  25. package/src/playback/renderers/factory.ts +172 -0
  26. package/src/playback/renderers/index.ts +5 -0
  27. package/src/playback/renderers/types.ts +57 -0
  28. package/src/playback/renderers/webgl.ts +373 -0
  29. package/src/playback/renderers/webgpu.ts +401 -0
  30. package/src/playlist/manager.ts +268 -0
  31. package/src/plugins/context.ts +93 -0
  32. package/src/plugins/index.ts +15 -0
  33. package/src/plugins/manager.ts +482 -0
  34. package/src/plugins/types.ts +243 -0
  35. package/src/sources/manager.ts +285 -0
  36. package/src/sources/source.ts +84 -0
  37. package/src/sources/types.ts +17 -0
  38. package/src/state/store.ts +389 -0
  39. package/src/state/types.ts +18 -0
  40. package/src/tracks/manager.ts +421 -0
  41. package/src/tracks/types.ts +30 -0
  42. package/src/types/jassub.d.ts +1 -0
  43. package/src/types.ts +235 -0
  44. package/src/utils/async-lock.ts +26 -0
  45. package/src/utils/dispose.ts +28 -0
  46. package/src/utils/equal.ts +33 -0
  47. package/src/utils/errors.ts +74 -0
  48. package/src/utils/logger.ts +50 -0
  49. package/src/utils/time.ts +157 -0
@@ -0,0 +1,1176 @@
1
+ import { CanvasSink, type InputVideoTrack, type VideoSample, VideoSampleSink, type WrappedCanvas } from 'mediabunny';
2
+ import type { PluginManager } from '../plugins/manager';
3
+ import type { IRenderer, RendererType, Rotation } from './renderers';
4
+ import { Canvas2DRenderer, RendererFactory } from './renderers';
5
+
6
+ /** @internal */
7
+ interface PrefetchedVideoData {
8
+ canvasSink: CanvasSink;
9
+ firstFrame: WrappedCanvas | null;
10
+ }
11
+
12
+ // Internal registry for prefetched video data, keyed by track
13
+ const prefetchedVideoDataRegistry = new WeakMap<InputVideoTrack, PrefetchedVideoData>();
14
+
15
+ /** @internal */
16
+ export function registerPrefetchedVideoData(track: InputVideoTrack, data: PrefetchedVideoData): void {
17
+ prefetchedVideoDataRegistry.set(track, data);
18
+ }
19
+
20
+ /** @internal */
21
+ export function consumePrefetchedVideoData(track: InputVideoTrack): PrefetchedVideoData | undefined {
22
+ const data = prefetchedVideoDataRegistry.get(track);
23
+ if (data) {
24
+ prefetchedVideoDataRegistry.delete(track);
25
+ }
26
+ return data;
27
+ }
28
+
29
+ export interface VideoRendererOptions {
30
+ canvas?: HTMLCanvasElement | OffscreenCanvas;
31
+ width?: number;
32
+ height?: number;
33
+ fit?: 'fill' | 'contain' | 'cover';
34
+ rotation?: 0 | 90 | 180 | 270;
35
+ poolSize?: number;
36
+ rendererType?: RendererType;
37
+ /** Enable debug logging for renderer operations (default: false) */
38
+ debug?: boolean;
39
+ }
40
+
41
+ export class VideoRenderer {
42
+ private canvas: HTMLCanvasElement | OffscreenCanvas | null = null;
43
+ private canvasSink: CanvasSink | null = null;
44
+ private sampleSink: VideoSampleSink | null = null;
45
+ private options: VideoRendererOptions;
46
+ private frameIterator: AsyncGenerator<WrappedCanvas, void, unknown> | null = null;
47
+ private currentFrame: WrappedCanvas | null = null;
48
+ private nextFrame: WrappedCanvas | null = null;
49
+ private disposed = false;
50
+ private renderingId = 0;
51
+ private renderer: IRenderer | null = null;
52
+ private rendererType: RendererType = 'canvas2d';
53
+ private onRendererChange?: (type: RendererType) => void;
54
+ private onRendererFallback?: (from: RendererType, to: RendererType) => void;
55
+ private onRotationChange?: (rotation: Rotation, displaySize: { width: number; height: number }) => void;
56
+ private initPromise: Promise<void> | null = null;
57
+ private resizeObserver: ResizeObserver | null = null;
58
+ private lastObservedWidth = 0;
59
+ private lastObservedHeight = 0;
60
+ private videoAspectRatio: string | null = null;
61
+ private debug = false;
62
+ private pluginManager: PluginManager | null = null;
63
+ private overlayCanvas: HTMLCanvasElement | null = null;
64
+ private overlayCtx: CanvasRenderingContext2D | null = null;
65
+ private lastOverlayTime = 0;
66
+ private rotation: Rotation = 0;
67
+ private sourceWidth = 0;
68
+ private sourceHeight = 0;
69
+ // Pre-allocated return object for updateFrame to avoid allocation per call (60fps)
70
+ private updateFrameResult = { frameUpdated: false, isStarving: false };
71
+
72
+ constructor(options: VideoRendererOptions = {}) {
73
+ this.options = {
74
+ poolSize: options.poolSize ?? 2,
75
+ rendererType: options.rendererType ?? 'webgpu',
76
+ ...options,
77
+ };
78
+
79
+ this.rendererType = this.options.rendererType ?? 'webgpu';
80
+ this.debug = options.debug ?? false;
81
+ this.initPromise = null;
82
+
83
+ if (options.canvas) {
84
+ this.canvas = options.canvas;
85
+
86
+ // Set canvas backing buffer dimensions if provided in options
87
+ if (this.options.width !== undefined) {
88
+ options.canvas.width = this.options.width;
89
+ }
90
+ if (this.options.height !== undefined) {
91
+ options.canvas.height = this.options.height;
92
+ }
93
+
94
+ // Start initialization immediately but store the promise
95
+ // We'll await it when needed
96
+ this.initPromise = this.initializeRenderer(options.canvas, this.rendererType).catch((err) => {
97
+ if (this.debug) console.error('Failed to initialize renderer:', err);
98
+ // Don't create fallback here - let initializeRenderer handle it
99
+ });
100
+
101
+ // Setup automatic resize handling for HTMLCanvasElement
102
+ this.setupResizeObserver(options.canvas);
103
+ }
104
+ }
105
+
106
+ private setupResizeObserver(canvas: HTMLCanvasElement | OffscreenCanvas): void {
107
+ // Only setup for HTMLCanvasElement in DOM
108
+ if (!('getBoundingClientRect' in canvas) || typeof ResizeObserver === 'undefined') {
109
+ return;
110
+ }
111
+
112
+ // Don't auto-resize if user provided explicit dimensions
113
+ if (this.options.width !== undefined || this.options.height !== undefined) {
114
+ return;
115
+ }
116
+
117
+ // Clean up any existing observer
118
+ this.cleanupResizeObserver();
119
+
120
+ const htmlCanvas = canvas as HTMLCanvasElement;
121
+
122
+ // Create resize observer to automatically update canvas dimensions
123
+ this.resizeObserver = new ResizeObserver((entries) => {
124
+ // Check if disposed to prevent memory leaks
125
+ if (this.disposed || !this.resizeObserver) {
126
+ return;
127
+ }
128
+
129
+ for (const entry of entries) {
130
+ const { width: newWidth, height: newHeight } = this.getCanvasDimensionsFromEntry(entry, htmlCanvas);
131
+
132
+ // Only update if dimensions changed
133
+ if (newWidth !== this.lastObservedWidth || newHeight !== this.lastObservedHeight) {
134
+ this.lastObservedWidth = newWidth;
135
+ this.lastObservedHeight = newHeight;
136
+
137
+ // Update canvas backing buffer
138
+ if (htmlCanvas.width !== newWidth || htmlCanvas.height !== newHeight) {
139
+ htmlCanvas.width = newWidth;
140
+ htmlCanvas.height = newHeight;
141
+
142
+ // Apply video aspect ratio if available
143
+ this.updateCanvasAspectRatio();
144
+
145
+ // Re-render current frame with new dimensions
146
+ if (this.currentFrame && this.renderer && this.renderer.isReady()) {
147
+ this.renderFrame(this.currentFrame);
148
+ }
149
+ }
150
+ }
151
+ }
152
+ });
153
+
154
+ // Try to observe with device-pixel-content-box first, fallback to content-box
155
+ try {
156
+ this.resizeObserver.observe(htmlCanvas, { box: 'device-pixel-content-box' });
157
+ } catch {
158
+ try {
159
+ this.resizeObserver.observe(htmlCanvas, { box: 'content-box' });
160
+ } catch {
161
+ // If both fail, observe without options
162
+ this.resizeObserver.observe(htmlCanvas);
163
+ }
164
+ }
165
+
166
+ // Defer initial dimensions to ensure proper layout
167
+ // Use requestAnimationFrame to wait for browser layout
168
+ requestAnimationFrame(() => {
169
+ // Check if disposed before applying dimensions
170
+ if (this.disposed || !this.resizeObserver) {
171
+ return;
172
+ }
173
+
174
+ const { width: initialWidth, height: initialHeight } = this.getCanvasDimensionsFromCanvas(htmlCanvas);
175
+
176
+ // Store dimensions
177
+ this.lastObservedWidth = initialWidth;
178
+ this.lastObservedHeight = initialHeight;
179
+
180
+ // Apply initial dimensions to canvas if they differ
181
+ if (htmlCanvas.width !== initialWidth || htmlCanvas.height !== initialHeight) {
182
+ htmlCanvas.width = initialWidth;
183
+ htmlCanvas.height = initialHeight;
184
+
185
+ // Apply video aspect ratio if available
186
+ this.updateCanvasAspectRatio();
187
+
188
+ // Re-render if we have a frame
189
+ if (this.currentFrame && this.renderer && this.renderer.isReady()) {
190
+ this.renderFrame(this.currentFrame);
191
+ }
192
+ }
193
+ });
194
+ }
195
+
196
+ private getCanvasDimensionsFromEntry(
197
+ entry: ResizeObserverEntry,
198
+ canvas: HTMLCanvasElement
199
+ ): { width: number; height: number } {
200
+ let width = 0;
201
+ let height = 0;
202
+ const dpr = window.devicePixelRatio || 1;
203
+
204
+ // Try device-pixel-content-box first (Chrome/Edge) - most accurate for canvas
205
+ if (entry.devicePixelContentBoxSize?.length) {
206
+ width = entry.devicePixelContentBoxSize[0].inlineSize;
207
+ height = entry.devicePixelContentBoxSize[0].blockSize;
208
+ }
209
+ // Fallback to contentBoxSize (Firefox/Safari)
210
+ else if (entry.contentBoxSize?.length) {
211
+ width = Math.round(entry.contentBoxSize[0].inlineSize * dpr);
212
+ height = Math.round(entry.contentBoxSize[0].blockSize * dpr);
213
+ }
214
+ // Fallback to contentRect
215
+ else if (entry.contentRect) {
216
+ width = Math.round(entry.contentRect.width * dpr);
217
+ height = Math.round(entry.contentRect.height * dpr);
218
+ }
219
+
220
+ // If still zero, get from canvas directly
221
+ if (width === 0 || height === 0) {
222
+ return this.getCanvasDimensionsFromCanvas(canvas);
223
+ }
224
+
225
+ // Return dimensions with minimum 1x1 guard
226
+ return {
227
+ width: Math.max(1, width),
228
+ height: Math.max(1, height),
229
+ };
230
+ }
231
+
232
+ private getCanvasDimensionsFromCanvas(canvas: HTMLCanvasElement): { width: number; height: number } {
233
+ let width = 0;
234
+ let height = 0;
235
+ const dpr = window.devicePixelRatio || 1;
236
+
237
+ // Try getBoundingClientRect first
238
+ const rect = canvas.getBoundingClientRect();
239
+ width = Math.round(rect.width * dpr);
240
+ height = Math.round(rect.height * dpr);
241
+
242
+ // If zero, fallback to clientWidth/clientHeight
243
+ if (width === 0 || height === 0) {
244
+ width = Math.round(canvas.clientWidth * dpr) || width;
245
+ height = Math.round(canvas.clientHeight * dpr) || height;
246
+ }
247
+
248
+ // Guard against zero dimensions - use minimum 1x1
249
+ if (width === 0 || height === 0) {
250
+ console.warn('Canvas has zero dimensions after all fallbacks, using 1x1');
251
+ }
252
+ return {
253
+ width: Math.max(1, width),
254
+ height: Math.max(1, height),
255
+ };
256
+ }
257
+
258
+ private cleanupResizeObserver(): void {
259
+ if (this.resizeObserver) {
260
+ this.resizeObserver.disconnect();
261
+ this.resizeObserver = null;
262
+ this.lastObservedWidth = 0;
263
+ this.lastObservedHeight = 0;
264
+ }
265
+ }
266
+
267
+ private retryUntilCanvasReady(frame: WrappedCanvas, action: () => void, maxRetries = 60): void {
268
+ let count = 0;
269
+ const retry = () => {
270
+ count++;
271
+ if (frame.canvas.width > 0 && frame.canvas.height > 0) {
272
+ action();
273
+ } else if (count < maxRetries) {
274
+ requestAnimationFrame(retry);
275
+ } else {
276
+ if (this.debug) console.warn('Canvas dimensions timeout, forcing action');
277
+ action(); // Try anyway
278
+ }
279
+ };
280
+ requestAnimationFrame(retry);
281
+ }
282
+
283
+ private updateCanvasAspectRatio(): void {
284
+ if (!this.canvas || !this.videoAspectRatio || !('style' in this.canvas)) {
285
+ return;
286
+ }
287
+
288
+ this.canvas.style.aspectRatio = this.videoAspectRatio;
289
+ }
290
+
291
+ private updateCanvasBackingBuffer(canvas: HTMLCanvasElement): boolean {
292
+ const { width, height } = this.getCanvasDimensionsFromCanvas(canvas);
293
+
294
+ if (canvas.width !== width || canvas.height !== height) {
295
+ canvas.width = width;
296
+ canvas.height = height;
297
+ return true;
298
+ }
299
+ return false;
300
+ }
301
+
302
+ private async initializeRenderer(canvas: HTMLCanvasElement | OffscreenCanvas, type: RendererType): Promise<void> {
303
+ if (this.debug) console.log(`Initializing renderer: ${type}`);
304
+
305
+ const factory = new RendererFactory({ canvas });
306
+ const result = await factory.createRendererWithFallback(type);
307
+
308
+ if (this.debug) console.log(`Renderer factory result: ${result.actualType}`);
309
+
310
+ // Verify new renderer is ready
311
+ if (!result.renderer.isReady()) {
312
+ if (this.debug) console.warn(`VideoRenderer: Renderer (${result.actualType}) not ready`);
313
+ result.renderer.dispose();
314
+ throw new Error(`Failed to initialize renderer: ${result.actualType}`);
315
+ }
316
+
317
+ // Set the renderer (first time initialization)
318
+ this.renderer = result.renderer;
319
+ this.rendererType = result.actualType;
320
+
321
+ if (this.debug) console.log(`Initialized renderer: ${this.rendererType}`);
322
+
323
+ // Emit events
324
+ if (result.actualType !== type) {
325
+ if (this.onRendererFallback) {
326
+ this.onRendererFallback(type, result.actualType);
327
+ }
328
+ }
329
+
330
+ // Always emit renderer change on initialization
331
+ if (this.onRendererChange) {
332
+ if (this.debug) console.log(`Emitting renderer change: ${this.rendererType}`);
333
+ this.onRendererChange(this.rendererType);
334
+ }
335
+
336
+ // IMPORTANT: Re-render current frame immediately if we have one
337
+ // This fixes the black screen on initial load
338
+ if (this.currentFrame && this.renderer && this.renderer.isReady()) {
339
+ if (this.debug) console.log(`Rendering initial frame with ${this.rendererType}`);
340
+
341
+ // Check if canvas has dimensions, if not, wait for them
342
+ if (this.currentFrame.canvas.width === 0 || this.currentFrame.canvas.height === 0) {
343
+ if (this.debug) console.log('Initial frame has zero dimensions, scheduling render when ready...');
344
+ this.retryUntilCanvasReady(this.currentFrame, () => {
345
+ if (this.currentFrame && this.debug) {
346
+ console.log(
347
+ `Canvas ready (${this.currentFrame.canvas.width}x${this.currentFrame.canvas.height}), rendering initial frame`
348
+ );
349
+ }
350
+ if (this.currentFrame) this.renderFrame(this.currentFrame);
351
+ });
352
+ } else {
353
+ this.renderFrame(this.currentFrame);
354
+ }
355
+ }
356
+ }
357
+
358
+ async setCanvas(canvas: HTMLCanvasElement | OffscreenCanvas): Promise<void> {
359
+ // Clean up old overlay canvas before switching
360
+ this.cleanupOverlayCanvas();
361
+
362
+ this.canvas = canvas;
363
+
364
+ // Clean up old renderer
365
+ if (this.renderer) {
366
+ this.renderer.dispose();
367
+ this.renderer = null;
368
+ }
369
+
370
+ // Set canvas backing buffer dimensions if provided in options
371
+ if (this.options.width !== undefined) {
372
+ canvas.width = this.options.width;
373
+ }
374
+ if (this.options.height !== undefined) {
375
+ canvas.height = this.options.height;
376
+ }
377
+
378
+ // Setup automatic resize handling for HTMLCanvasElement
379
+ this.setupResizeObserver(canvas);
380
+
381
+ // Initialize renderer without pre-creating Canvas2D
382
+ try {
383
+ await this.initializeRenderer(canvas, this.rendererType);
384
+ } catch (err) {
385
+ if (this.debug) console.error('Failed to initialize renderer:', err);
386
+ // If all else fails, create Canvas2D as last resort
387
+ if (!this.renderer) {
388
+ this.renderer = new Canvas2DRenderer({ canvas });
389
+ this.rendererType = 'canvas2d';
390
+ if (this.onRendererChange) {
391
+ this.onRendererChange('canvas2d');
392
+ }
393
+ }
394
+ }
395
+ }
396
+
397
+ async setVideoTrack(track: InputVideoTrack): Promise<void> {
398
+ // Dispose only video track resources, not the renderer
399
+ await this.disposeVideoResources();
400
+
401
+ // Check if we can decode before throwing
402
+ if (track.codec === null) {
403
+ throw new Error(`Unsupported video codec`);
404
+ }
405
+
406
+ const canDecode = await track.canDecode();
407
+ if (!canDecode) {
408
+ throw new Error(`Cannot decode video track with codec: ${track.codec}`);
409
+ }
410
+
411
+ // Check for prefetched data (internal optimization)
412
+ const prefetchedData = consumePrefetchedVideoData(track);
413
+
414
+ // Store source dimensions for rotation calculations
415
+ this.sourceWidth = track.displayWidth;
416
+ this.sourceHeight = track.displayHeight;
417
+
418
+ // Calculate the video's aspect ratio from its dimensions (only once per track)
419
+ if (!this.videoAspectRatio && track.displayWidth && track.displayHeight) {
420
+ const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
421
+ const divisor = gcd(track.displayWidth, track.displayHeight);
422
+ const aspectWidth = track.displayWidth / divisor;
423
+ const aspectHeight = track.displayHeight / divisor;
424
+ this.videoAspectRatio = `${aspectWidth}/${aspectHeight}`;
425
+
426
+ // Apply aspect ratio to canvas
427
+ this.updateCanvasAspectRatio();
428
+ }
429
+
430
+ // Notify rotation change with initial display size
431
+ this.notifyRotationChange();
432
+
433
+ // Wait for renderer initialization to complete
434
+ if (this.initPromise) {
435
+ try {
436
+ await this.initPromise;
437
+ } catch (err) {
438
+ if (this.debug) console.error('Renderer initialization failed:', err);
439
+ // Continue anyway, we'll handle it later
440
+ }
441
+ }
442
+
443
+ // If still no renderer, create Canvas2D fallback
444
+ if (!this.renderer) {
445
+ if (this.debug) console.warn('Renderer not ready, creating Canvas2D fallback');
446
+ if (this.canvas) {
447
+ this.renderer = new Canvas2DRenderer({ canvas: this.canvas });
448
+ this.rendererType = 'canvas2d';
449
+ if (this.onRendererChange) {
450
+ this.onRendererChange('canvas2d');
451
+ }
452
+ }
453
+ }
454
+
455
+ // Set canvas backing buffer dimensions for optimal quality
456
+ if (this.canvas) {
457
+ // If user provided explicit dimensions, use those
458
+ if (this.options.width !== undefined || this.options.height !== undefined) {
459
+ const targetWidth = this.options.width ?? track.displayWidth;
460
+ const targetHeight = this.options.height ?? track.displayHeight;
461
+
462
+ if (this.canvas.width !== targetWidth || this.canvas.height !== targetHeight) {
463
+ this.canvas.width = targetWidth;
464
+ this.canvas.height = targetHeight;
465
+
466
+ // Apply video aspect ratio if available
467
+ this.updateCanvasAspectRatio();
468
+ }
469
+ }
470
+ // If ResizeObserver is active, it will handle dimensions
471
+ else if (!this.resizeObserver) {
472
+ // No ResizeObserver and no explicit dimensions, default to video dimensions
473
+ if (this.canvas.width === 0 || this.canvas.height === 0) {
474
+ this.canvas.width = track.displayWidth;
475
+ this.canvas.height = track.displayHeight;
476
+
477
+ // Apply video aspect ratio if available
478
+ this.updateCanvasAspectRatio();
479
+ }
480
+ }
481
+ // Otherwise ResizeObserver is handling dimensions automatically
482
+ }
483
+
484
+ // Use prefetched sink if available, otherwise create new
485
+ if (prefetchedData?.canvasSink) {
486
+ this.canvasSink = prefetchedData.canvasSink;
487
+ } else {
488
+ // Create sinks
489
+ // CanvasSink creates intermediate canvases at native video size
490
+ // Renderers handle scaling to display canvas (downscale or 1:1, never upscale beyond native)
491
+ this.canvasSink = new CanvasSink(track, {
492
+ // Use native video dimensions for maximum quality
493
+ // Renderers will letterbox when rendering to display canvas
494
+ rotation: this.options.rotation,
495
+ poolSize: this.options.poolSize,
496
+ });
497
+ }
498
+
499
+ this.sampleSink = new VideoSampleSink(track);
500
+
501
+ // Allow rendering again now that resources are initialized
502
+ this.disposed = false;
503
+
504
+ // Use prefetched first frame if available for instant display
505
+ if (prefetchedData?.firstFrame) {
506
+ this.currentFrame = prefetchedData.firstFrame;
507
+
508
+ // Render immediately
509
+ if (this.currentFrame.canvas.width > 0 && this.currentFrame.canvas.height > 0) {
510
+ this.renderFrame(this.currentFrame);
511
+ } else {
512
+ this.retryUntilCanvasReady(
513
+ this.currentFrame,
514
+ () => {
515
+ if (this.currentFrame) this.renderFrame(this.currentFrame);
516
+ },
517
+ 30
518
+ );
519
+ }
520
+
521
+ // Start iterator from 0 for next frames
522
+ this.frameIterator = this.canvasSink.canvases(0);
523
+ // Skip first frame since we already have it
524
+ void this.frameIterator.next().then(() => {
525
+ void this.fetchNextFrame();
526
+ });
527
+ } else {
528
+ // Initialize the first frame normally
529
+ try {
530
+ await this.seek(0);
531
+ } catch (err) {
532
+ if (this.debug) console.error('Initial seek failed:', err);
533
+ }
534
+ }
535
+
536
+ // Single render attempt after layout
537
+ requestAnimationFrame(() => {
538
+ if (this.resizeObserver && this.canvas && 'getBoundingClientRect' in this.canvas) {
539
+ this.updateCanvasBackingBuffer(this.canvas as HTMLCanvasElement);
540
+ }
541
+
542
+ if (this.currentFrame && this.renderer && this.renderer.isReady()) {
543
+ this.renderFrame(this.currentFrame);
544
+ }
545
+ });
546
+ }
547
+
548
+ async seek(timestamp: number): Promise<void> {
549
+ if (!this.canvasSink) {
550
+ return;
551
+ }
552
+
553
+ this.renderingId++;
554
+ const currentRenderingId = this.renderingId;
555
+
556
+ // Dispose current iterator
557
+ if (this.frameIterator) {
558
+ try {
559
+ await this.frameIterator.return();
560
+ } catch {
561
+ // Iterator may already be closed
562
+ }
563
+ this.frameIterator = null;
564
+ }
565
+
566
+ // Create a new iterator starting from the timestamp
567
+ const iterator = this.canvasSink.canvases(timestamp);
568
+ this.frameIterator = iterator;
569
+
570
+ try {
571
+ // Get the first two frames
572
+ const firstResult = await iterator.next();
573
+ const secondResult = await iterator.next();
574
+
575
+ if (currentRenderingId !== this.renderingId) {
576
+ return;
577
+ }
578
+
579
+ const firstFrame = firstResult.value ?? null;
580
+ const secondFrame = secondResult.value ?? null;
581
+
582
+ // Store the frame first
583
+ if (firstFrame) {
584
+ this.currentFrame = firstFrame;
585
+
586
+ // Draw the first frame immediately if canvas has dimensions
587
+ if (firstFrame.canvas.width > 0 && firstFrame.canvas.height > 0) {
588
+ this.renderFrame(firstFrame);
589
+ } else {
590
+ this.retryUntilCanvasReady(firstFrame, () => this.renderFrame(firstFrame), 30);
591
+ }
592
+ }
593
+
594
+ // Store the second frame for later
595
+ this.nextFrame = secondFrame;
596
+
597
+ // If we don't have a next frame yet, try to fetch one
598
+ if (!this.nextFrame) {
599
+ void this.fetchNextFrame();
600
+ }
601
+ } catch {
602
+ // Iterator was closed or disposed during seek
603
+ }
604
+ }
605
+
606
+ updateFrame(currentTime: number): { frameUpdated: boolean; isStarving: boolean } {
607
+ const result = this.updateFrameResult;
608
+
609
+ if (this.disposed) {
610
+ result.frameUpdated = false;
611
+ result.isStarving = false;
612
+ return result;
613
+ }
614
+
615
+ // If we don't have a next frame, request one (if iterator exists)
616
+ // This is frame starvation - we're playing but have no frame ready
617
+ if (!this.nextFrame) {
618
+ if (this.frameIterator) {
619
+ void this.fetchNextFrame();
620
+ }
621
+ result.frameUpdated = false;
622
+ result.isStarving = true;
623
+ return result;
624
+ }
625
+
626
+ // Check if the current playback time has caught up to the next frame
627
+ if (this.nextFrame.timestamp <= currentTime) {
628
+ // Store the frame first
629
+ this.currentFrame = this.nextFrame;
630
+ this.nextFrame = null;
631
+
632
+ // Draw the frame (renderer might not be ready yet)
633
+ this.renderFrame(this.currentFrame);
634
+
635
+ // Request the next frame asynchronously (only if iterator still exists)
636
+ if (this.frameIterator) {
637
+ void this.fetchNextFrame();
638
+ }
639
+ result.frameUpdated = true;
640
+ result.isStarving = false;
641
+ return result;
642
+ }
643
+
644
+ result.frameUpdated = false;
645
+ result.isStarving = false;
646
+ return result;
647
+ }
648
+
649
+ private async fetchNextFrame(): Promise<void> {
650
+ const iterator = this.frameIterator;
651
+ if (!iterator || this.disposed) return;
652
+
653
+ const currentRenderingId = this.renderingId;
654
+
655
+ try {
656
+ // Get the next frame from iterator
657
+ const result = await iterator.next();
658
+ const frame = result.value ?? null;
659
+
660
+ if (!frame || currentRenderingId !== this.renderingId || this.disposed) {
661
+ return;
662
+ }
663
+
664
+ // Store the frame for later use
665
+ this.nextFrame = frame;
666
+ } catch {
667
+ // Iterator was closed or disposed during fetch
668
+ }
669
+ }
670
+
671
+ private renderFrame(frame: WrappedCanvas): void {
672
+ // Store current frame for potential re-rendering
673
+ this.currentFrame = frame;
674
+
675
+ if (!this.renderer || !this.canvas) {
676
+ // This can happen if rendering is attempted before setup completes
677
+ // Try to render once renderer is ready
678
+ if (this.initPromise) {
679
+ this.initPromise.then(() => {
680
+ if (this.currentFrame === frame && this.renderer && this.renderer.isReady()) {
681
+ if (this.debug) console.log('Rendering frame after renderer initialization');
682
+ this.renderFrameWithPlugins(frame);
683
+ }
684
+ });
685
+ }
686
+ return;
687
+ }
688
+
689
+ if (!this.renderer.isReady()) {
690
+ if (this.debug) console.warn(`VideoRenderer: Renderer (${this.rendererType}) not ready, skipping frame`);
691
+ return;
692
+ }
693
+
694
+ this.renderFrameWithPlugins(frame);
695
+ }
696
+
697
+ private renderFrameWithPlugins(frame: WrappedCanvas): void {
698
+ if (!this.renderer || !this.canvas) return;
699
+
700
+ const time = frame.timestamp;
701
+
702
+ // Execute beforeRender hooks
703
+ if (this.pluginManager) {
704
+ const result = this.pluginManager.executeBeforeRender(frame, time);
705
+ if (result?.skip) return;
706
+ }
707
+
708
+ // Execute transformFrame hooks
709
+ let processedFrame = frame;
710
+ if (this.pluginManager) {
711
+ processedFrame = this.pluginManager.executeTransformFrame(frame);
712
+ }
713
+
714
+ // Use renderer to draw frame
715
+ const success = this.renderer.render(processedFrame.canvas);
716
+ if (!success) {
717
+ if (this.debug) {
718
+ console.warn(
719
+ `Failed to render frame with ${this.rendererType} (canvas: ${processedFrame.canvas.width}x${processedFrame.canvas.height})`
720
+ );
721
+ }
722
+
723
+ // If render failed due to zero dimensions, retry on next frame
724
+ if (processedFrame.canvas.width === 0 || processedFrame.canvas.height === 0) {
725
+ this.retryUntilCanvasReady(
726
+ processedFrame,
727
+ () => {
728
+ if (this.currentFrame === frame && this.renderer && this.renderer.isReady()) {
729
+ const retrySuccess = this.renderer.render(processedFrame.canvas);
730
+ if (!retrySuccess && this.debug) {
731
+ console.warn('Retry render also failed');
732
+ }
733
+ }
734
+ },
735
+ 1
736
+ ); // Just one retry for render failures
737
+ }
738
+ return;
739
+ }
740
+
741
+ // Execute overlay hooks
742
+ this.executeOverlays(time);
743
+
744
+ // Execute afterRender hooks
745
+ if (this.pluginManager) {
746
+ this.pluginManager.executeAfterRender(this.canvas);
747
+ }
748
+ }
749
+
750
+ private executeOverlays(time: number): void {
751
+ if (!this.pluginManager || !this.canvas) return;
752
+
753
+ this.lastOverlayTime = time;
754
+ const dimensions = { width: this.canvas.width, height: this.canvas.height };
755
+
756
+ if (this.rendererType === 'canvas2d') {
757
+ // For canvas2d, we can draw directly on the canvas
758
+ const ctx = (this.canvas as HTMLCanvasElement).getContext('2d');
759
+ if (!ctx) return;
760
+
761
+ this.pluginManager.executeOverlays(ctx, time, dimensions);
762
+ } else {
763
+ // For WebGPU/WebGL, we use a separate overlay canvas positioned on top
764
+ // This is necessary because WebGPU/WebGL contexts don't support 2D drawing
765
+ this.ensureOverlayCanvas();
766
+ if (!this.overlayCanvas || !this.overlayCtx) return;
767
+
768
+ // Clear overlay canvas
769
+ this.overlayCtx.clearRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height);
770
+
771
+ // Execute overlay hooks on the overlay canvas
772
+ this.pluginManager.executeOverlays(this.overlayCtx, time, dimensions);
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Refresh overlays immediately (e.g., when a plugin is installed/uninstalled).
778
+ * For WebGPU/WebGL, this clears and redraws the overlay canvas.
779
+ * For canvas2d, this triggers a frame re-render if a current frame exists.
780
+ */
781
+ refreshOverlays(): void {
782
+ if (!this.canvas) return;
783
+
784
+ if (this.rendererType === 'canvas2d') {
785
+ // For canvas2d, we need to re-render the current frame to update overlays
786
+ if (this.currentFrame && this.renderer?.isReady()) {
787
+ this.renderer.render(this.currentFrame.canvas);
788
+ this.executeOverlays(this.lastOverlayTime);
789
+ }
790
+ } else {
791
+ // For WebGPU/WebGL, just clear and redraw the overlay canvas
792
+ if (this.overlayCanvas && this.overlayCtx) {
793
+ this.overlayCtx.clearRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height);
794
+ if (this.pluginManager) {
795
+ const dimensions = { width: this.canvas.width, height: this.canvas.height };
796
+ this.pluginManager.executeOverlays(this.overlayCtx, this.lastOverlayTime, dimensions);
797
+ }
798
+ }
799
+ }
800
+ }
801
+
802
+ private ensureOverlayCanvas(): void {
803
+ if (!this.canvas || !(this.canvas instanceof HTMLCanvasElement)) return;
804
+
805
+ // Create overlay canvas if needed
806
+ if (!this.overlayCanvas) {
807
+ this.overlayCanvas = document.createElement('canvas');
808
+ this.overlayCanvas.style.position = 'absolute';
809
+ this.overlayCanvas.style.top = '0';
810
+ this.overlayCanvas.style.left = '0';
811
+ this.overlayCanvas.style.width = '100%';
812
+ this.overlayCanvas.style.height = '100%';
813
+ this.overlayCanvas.style.pointerEvents = 'none';
814
+ this.overlayCanvas.style.zIndex = '1'; // Ensure overlay appears above main canvas
815
+ this.overlayCtx = this.overlayCanvas.getContext('2d');
816
+
817
+ // Insert overlay canvas after the main canvas
818
+ const parent = this.canvas.parentElement;
819
+ if (parent) {
820
+ // Ensure parent has relative positioning for absolute overlay
821
+ const parentStyle = getComputedStyle(parent);
822
+ if (parentStyle.position === 'static') {
823
+ parent.style.position = 'relative';
824
+ }
825
+ parent.insertBefore(this.overlayCanvas, this.canvas.nextSibling);
826
+ }
827
+ }
828
+
829
+ // Sync dimensions (backing buffer size)
830
+ if (this.overlayCanvas.width !== this.canvas.width || this.overlayCanvas.height !== this.canvas.height) {
831
+ this.overlayCanvas.width = this.canvas.width;
832
+ this.overlayCanvas.height = this.canvas.height;
833
+ }
834
+ }
835
+
836
+ private cleanupOverlayCanvas(): void {
837
+ if (this.overlayCanvas) {
838
+ this.overlayCanvas.remove();
839
+ this.overlayCanvas = null;
840
+ this.overlayCtx = null;
841
+ }
842
+ }
843
+
844
+ async getFrameAt(timestamp: number): Promise<WrappedCanvas | null> {
845
+ if (!this.canvasSink) return null;
846
+ return this.canvasSink.getCanvas(timestamp);
847
+ }
848
+
849
+ async getSampleAt(timestamp: number): Promise<VideoSample | null> {
850
+ if (!this.sampleSink) return null;
851
+ return this.sampleSink.getSample(timestamp);
852
+ }
853
+
854
+ async extractFrames(startTime: number, endTime: number, interval: number = 1): Promise<WrappedCanvas[]> {
855
+ if (!this.canvasSink) return [];
856
+
857
+ const frames: WrappedCanvas[] = [];
858
+ const timestamps: number[] = [];
859
+
860
+ for (let t = startTime; t <= endTime; t += interval) {
861
+ timestamps.push(t);
862
+ }
863
+
864
+ for await (const frame of this.canvasSink.canvasesAtTimestamps(timestamps)) {
865
+ if (frame) frames.push(frame);
866
+ }
867
+
868
+ return frames;
869
+ }
870
+
871
+ async screenshot(
872
+ timestamp?: number,
873
+ options: {
874
+ format?: 'png' | 'jpeg' | 'webp';
875
+ quality?: number;
876
+ } = {}
877
+ ): Promise<Blob | null> {
878
+ if (!this.canvas) return null;
879
+
880
+ // If timestamp provided, render that frame first
881
+ if (timestamp !== undefined && this.canvasSink) {
882
+ const frame = await this.canvasSink.getCanvas(timestamp);
883
+ if (frame) {
884
+ this.renderFrame(frame);
885
+ }
886
+ }
887
+
888
+ // Convert canvas to blob
889
+ if ('toBlob' in this.canvas) {
890
+ return new Promise((resolve) => {
891
+ (this.canvas as HTMLCanvasElement).toBlob(
892
+ (blob) => resolve(blob),
893
+ `image/${options.format ?? 'png'}`,
894
+ options.quality
895
+ );
896
+ });
897
+ } else {
898
+ // OffscreenCanvas
899
+ const offscreenCanvas = this.canvas as OffscreenCanvas;
900
+ return offscreenCanvas.convertToBlob({
901
+ type: `image/${options.format ?? 'png'}`,
902
+ quality: options.quality,
903
+ });
904
+ }
905
+ }
906
+
907
+ getCurrentFrame(): WrappedCanvas | null {
908
+ return this.currentFrame;
909
+ }
910
+
911
+ getNextFrame(): WrappedCanvas | null {
912
+ return this.nextFrame;
913
+ }
914
+
915
+ getRendererType(): RendererType {
916
+ return this.rendererType;
917
+ }
918
+
919
+ getCanvas(): HTMLCanvasElement | OffscreenCanvas | null {
920
+ return this.canvas;
921
+ }
922
+
923
+ /**
924
+ * Updates canvas backing buffer dimensions to match its CSS display size.
925
+ * Call this after changing CSS dimensions to prevent stretching.
926
+ * Only works for HTMLCanvasElement in DOM.
927
+ */
928
+ updateCanvasDimensions(): void {
929
+ if (!this.canvas || !('getBoundingClientRect' in this.canvas)) {
930
+ return;
931
+ }
932
+
933
+ const htmlCanvas = this.canvas as HTMLCanvasElement;
934
+ const dimensionsChanged = this.updateCanvasBackingBuffer(htmlCanvas);
935
+
936
+ if (dimensionsChanged && this.currentFrame && this.renderer && this.renderer.isReady()) {
937
+ this.renderFrame(this.currentFrame);
938
+ }
939
+ }
940
+
941
+ async switchRenderer(type: RendererType): Promise<void> {
942
+ if (!this.canvas) {
943
+ throw new Error('Cannot switch renderer: No canvas set');
944
+ }
945
+
946
+ const previousType = this.rendererType;
947
+
948
+ // If switching to the same type, do nothing
949
+ if (type === previousType) {
950
+ return;
951
+ }
952
+
953
+ if (this.debug)
954
+ console.warn(`Switching renderer from ${previousType} to ${type}. This will recreate the canvas element.`);
955
+
956
+ // For HTMLCanvasElement, we need to recreate it to switch context types
957
+ if (this.canvas instanceof HTMLCanvasElement) {
958
+ const oldCanvas = this.canvas;
959
+ const parent = oldCanvas.parentElement;
960
+
961
+ if (!parent) {
962
+ throw new Error('Cannot switch renderer: Canvas has no parent element');
963
+ }
964
+
965
+ // Create new canvas with same properties
966
+ const newCanvas = document.createElement('canvas');
967
+ newCanvas.width = oldCanvas.width;
968
+ newCanvas.height = oldCanvas.height;
969
+ newCanvas.className = oldCanvas.className;
970
+ newCanvas.id = oldCanvas.id;
971
+ newCanvas.style.cssText = oldCanvas.style.cssText;
972
+
973
+ // Copy all attributes
974
+ Array.from(oldCanvas.attributes).forEach((attr) => {
975
+ if (attr.name !== 'id' && attr.name !== 'class' && attr.name !== 'style') {
976
+ newCanvas.setAttribute(attr.name, attr.value);
977
+ }
978
+ });
979
+
980
+ // Clean up old renderer
981
+ if (this.renderer) {
982
+ this.renderer.dispose();
983
+ this.renderer = null;
984
+ }
985
+
986
+ // Replace canvas in DOM
987
+ parent.replaceChild(newCanvas, oldCanvas);
988
+ this.canvas = newCanvas;
989
+
990
+ // Initialize new renderer
991
+ try {
992
+ await this.initializeRenderer(newCanvas, type);
993
+ } catch (err) {
994
+ if (this.debug) console.error(`Failed to switch to ${type}:`, err);
995
+ // Try to fall back to Canvas2D
996
+ if (!this.renderer) {
997
+ this.renderer = new Canvas2DRenderer({ canvas: newCanvas });
998
+ this.rendererType = 'canvas2d';
999
+ if (this.onRendererChange) {
1000
+ this.onRendererChange('canvas2d');
1001
+ }
1002
+ }
1003
+ }
1004
+ } else {
1005
+ // For OffscreenCanvas, we can't recreate it, so just try to switch
1006
+ // This will likely fail if the context is already set
1007
+ if (this.debug) console.warn('Runtime switching for OffscreenCanvas may not work if context is already set');
1008
+
1009
+ // Clean up old renderer
1010
+ if (this.renderer) {
1011
+ this.renderer.dispose();
1012
+ this.renderer = null;
1013
+ }
1014
+
1015
+ try {
1016
+ await this.initializeRenderer(this.canvas, type);
1017
+ } catch (err) {
1018
+ if (this.debug) console.error(`Failed to switch to ${type}:`, err);
1019
+ // Try to fall back to Canvas2D
1020
+ if (!this.renderer) {
1021
+ this.renderer = new Canvas2DRenderer({ canvas: this.canvas });
1022
+ this.rendererType = 'canvas2d';
1023
+ if (this.onRendererChange) {
1024
+ this.onRendererChange('canvas2d');
1025
+ }
1026
+ }
1027
+ }
1028
+ }
1029
+
1030
+ // Re-render current frame with new renderer
1031
+ if (this.currentFrame && this.renderer && this.renderer.isReady()) {
1032
+ if (this.debug) console.log(`Re-rendering after switch to ${this.rendererType}`);
1033
+ // Use microtask for immediate re-render after context switch
1034
+ queueMicrotask(() => {
1035
+ if (this.currentFrame && this.renderer && this.renderer.isReady()) {
1036
+ this.renderFrame(this.currentFrame);
1037
+ }
1038
+ });
1039
+ }
1040
+ }
1041
+
1042
+ setRendererChangeCallback(callback: (type: RendererType) => void): void {
1043
+ this.onRendererChange = callback;
1044
+
1045
+ // If renderer is already initialized, emit immediately
1046
+ if (this.renderer && this.rendererType) {
1047
+ if (this.debug) console.log(`Renderer already initialized as ${this.rendererType}, emitting change event`);
1048
+ callback(this.rendererType);
1049
+ }
1050
+ }
1051
+
1052
+ setRendererFallbackCallback(callback: (from: RendererType, to: RendererType) => void): void {
1053
+ this.onRendererFallback = callback;
1054
+ }
1055
+
1056
+ setRotationChangeCallback(
1057
+ callback: (rotation: Rotation, displaySize: { width: number; height: number }) => void
1058
+ ): void {
1059
+ this.onRotationChange = callback;
1060
+ }
1061
+
1062
+ setRotation(rotation: Rotation): void {
1063
+ if (this.rotation === rotation) return;
1064
+
1065
+ this.rotation = rotation;
1066
+
1067
+ // Update renderer rotation
1068
+ if (this.renderer) {
1069
+ this.renderer.setRotation(rotation);
1070
+ }
1071
+
1072
+ // Notify about rotation change with new display size
1073
+ this.notifyRotationChange();
1074
+
1075
+ // Re-render current frame with new rotation
1076
+ if (this.currentFrame && this.renderer && this.renderer.isReady()) {
1077
+ this.renderFrame(this.currentFrame);
1078
+ }
1079
+ }
1080
+
1081
+ getRotation(): Rotation {
1082
+ return this.rotation;
1083
+ }
1084
+
1085
+ getDisplaySize(): { width: number; height: number } {
1086
+ const isRotated90or270 = this.rotation === 90 || this.rotation === 270;
1087
+ return {
1088
+ width: isRotated90or270 ? this.sourceHeight : this.sourceWidth,
1089
+ height: isRotated90or270 ? this.sourceWidth : this.sourceHeight,
1090
+ };
1091
+ }
1092
+
1093
+ private notifyRotationChange(): void {
1094
+ if (this.onRotationChange && this.sourceWidth > 0 && this.sourceHeight > 0) {
1095
+ this.onRotationChange(this.rotation, this.getDisplaySize());
1096
+ }
1097
+ }
1098
+
1099
+ setPluginManager(pluginManager: PluginManager): void {
1100
+ this.pluginManager = pluginManager;
1101
+ }
1102
+
1103
+ static getSupportedRenderers(): RendererType[] {
1104
+ return RendererFactory.getSupportedRenderers();
1105
+ }
1106
+
1107
+ /**
1108
+ * Clears iterators to stop any in-flight async operations.
1109
+ * Called before disposing the input to prevent accessing disposed resources.
1110
+ */
1111
+ async clearIterators(): Promise<void> {
1112
+ this.renderingId++;
1113
+
1114
+ if (this.frameIterator) {
1115
+ try {
1116
+ await this.frameIterator.return();
1117
+ } catch {
1118
+ // Iterator may already be closed
1119
+ }
1120
+ this.frameIterator = null;
1121
+ }
1122
+
1123
+ this.currentFrame = null;
1124
+ this.nextFrame = null;
1125
+ }
1126
+
1127
+ private async disposeVideoResources(): Promise<void> {
1128
+ this.disposed = true;
1129
+ this.renderingId++;
1130
+
1131
+ if (this.frameIterator) {
1132
+ try {
1133
+ await this.frameIterator.return();
1134
+ } catch {
1135
+ // Iterator may already be closed
1136
+ }
1137
+ this.frameIterator = null;
1138
+ }
1139
+
1140
+ this.currentFrame = null;
1141
+ this.nextFrame = null;
1142
+ this.canvasSink = null;
1143
+ this.sampleSink = null;
1144
+ this.videoAspectRatio = null;
1145
+ }
1146
+
1147
+ dispose(): void {
1148
+ this.disposed = true;
1149
+ this.renderingId++;
1150
+
1151
+ if (this.frameIterator) {
1152
+ // fire-and-forget – safe cleanup without throwing
1153
+ void this.frameIterator.return();
1154
+ this.frameIterator = null;
1155
+ }
1156
+
1157
+ if (this.renderer) {
1158
+ this.renderer.dispose();
1159
+ this.renderer = null;
1160
+ }
1161
+
1162
+ // Clean up resize observer
1163
+ this.cleanupResizeObserver();
1164
+
1165
+ // Clean up overlay canvas
1166
+ this.cleanupOverlayCanvas();
1167
+
1168
+ this.currentFrame = null;
1169
+ this.nextFrame = null;
1170
+ this.canvasSink = null;
1171
+ this.sampleSink = null;
1172
+ this.onRendererChange = undefined;
1173
+ this.onRendererFallback = undefined;
1174
+ // Track reference cleared through dispose of iterators
1175
+ }
1176
+ }