@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.
- package/dist/compositor/source-pool.d.ts.map +1 -1
- package/dist/compositor-worker.js +1 -227
- package/dist/index.js +3 -3
- package/dist/playback/renderers/webgpu.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/compositor/audio-manager.ts +411 -0
- package/src/compositor/compositor-worker.ts +158 -0
- package/src/compositor/compositor.ts +931 -0
- package/src/compositor/index.ts +19 -0
- package/src/compositor/source-pool.ts +489 -0
- package/src/compositor/types.ts +103 -0
- package/src/compositor/worker-client.ts +139 -0
- package/src/compositor/worker-types.ts +67 -0
- package/src/core/player-core.ts +273 -0
- package/src/core/state-facade.ts +98 -0
- package/src/core/track-switcher.ts +127 -0
- package/src/events/emitter.ts +137 -0
- package/src/events/types.ts +24 -0
- package/src/index.ts +124 -0
- package/src/mediafox.ts +642 -0
- package/src/playback/audio.ts +361 -0
- package/src/playback/controller.ts +446 -0
- package/src/playback/renderer.ts +1176 -0
- package/src/playback/renderers/canvas2d.ts +128 -0
- package/src/playback/renderers/factory.ts +172 -0
- package/src/playback/renderers/index.ts +5 -0
- package/src/playback/renderers/types.ts +57 -0
- package/src/playback/renderers/webgl.ts +373 -0
- package/src/playback/renderers/webgpu.ts +401 -0
- package/src/playlist/manager.ts +268 -0
- package/src/plugins/context.ts +93 -0
- package/src/plugins/index.ts +15 -0
- package/src/plugins/manager.ts +482 -0
- package/src/plugins/types.ts +243 -0
- package/src/sources/manager.ts +285 -0
- package/src/sources/source.ts +84 -0
- package/src/sources/types.ts +17 -0
- package/src/state/store.ts +389 -0
- package/src/state/types.ts +18 -0
- package/src/tracks/manager.ts +421 -0
- package/src/tracks/types.ts +30 -0
- package/src/types/jassub.d.ts +1 -0
- package/src/types.ts +235 -0
- package/src/utils/async-lock.ts +26 -0
- package/src/utils/dispose.ts +28 -0
- package/src/utils/equal.ts +33 -0
- package/src/utils/errors.ts +74 -0
- package/src/utils/logger.ts +50 -0
- 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
|
+
}
|