@mediafox/core 1.2.8 → 1.2.10

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 (48) hide show
  1. package/dist/compositor/compositor.d.ts.map +1 -1
  2. package/dist/compositor-worker.js +1 -1
  3. package/dist/index.js +1 -1
  4. package/package.json +4 -3
  5. package/src/compositor/audio-manager.ts +411 -0
  6. package/src/compositor/compositor-worker.ts +158 -0
  7. package/src/compositor/compositor.ts +931 -0
  8. package/src/compositor/index.ts +19 -0
  9. package/src/compositor/source-pool.ts +450 -0
  10. package/src/compositor/types.ts +103 -0
  11. package/src/compositor/worker-client.ts +139 -0
  12. package/src/compositor/worker-types.ts +67 -0
  13. package/src/core/player-core.ts +273 -0
  14. package/src/core/state-facade.ts +98 -0
  15. package/src/core/track-switcher.ts +127 -0
  16. package/src/events/emitter.ts +137 -0
  17. package/src/events/types.ts +24 -0
  18. package/src/index.ts +124 -0
  19. package/src/mediafox.ts +642 -0
  20. package/src/playback/audio.ts +361 -0
  21. package/src/playback/controller.ts +446 -0
  22. package/src/playback/renderer.ts +1176 -0
  23. package/src/playback/renderers/canvas2d.ts +128 -0
  24. package/src/playback/renderers/factory.ts +172 -0
  25. package/src/playback/renderers/index.ts +5 -0
  26. package/src/playback/renderers/types.ts +57 -0
  27. package/src/playback/renderers/webgl.ts +373 -0
  28. package/src/playback/renderers/webgpu.ts +395 -0
  29. package/src/playlist/manager.ts +268 -0
  30. package/src/plugins/context.ts +93 -0
  31. package/src/plugins/index.ts +15 -0
  32. package/src/plugins/manager.ts +482 -0
  33. package/src/plugins/types.ts +243 -0
  34. package/src/sources/manager.ts +285 -0
  35. package/src/sources/source.ts +84 -0
  36. package/src/sources/types.ts +17 -0
  37. package/src/state/store.ts +389 -0
  38. package/src/state/types.ts +18 -0
  39. package/src/tracks/manager.ts +421 -0
  40. package/src/tracks/types.ts +30 -0
  41. package/src/types/jassub.d.ts +1 -0
  42. package/src/types.ts +235 -0
  43. package/src/utils/async-lock.ts +26 -0
  44. package/src/utils/dispose.ts +28 -0
  45. package/src/utils/equal.ts +33 -0
  46. package/src/utils/errors.ts +74 -0
  47. package/src/utils/logger.ts +50 -0
  48. package/src/utils/time.ts +157 -0
@@ -0,0 +1,931 @@
1
+ import { EventEmitter } from '../events/emitter';
2
+ import type { MediaSource } from '../types';
3
+ import { CompositorAudioManager } from './audio-manager';
4
+ import { SourcePool } from './source-pool';
5
+ import type {
6
+ AudioLayer,
7
+ CompositionFrame,
8
+ CompositorEventListener,
9
+ CompositorEventMap,
10
+ CompositorLayer,
11
+ CompositorOptions,
12
+ CompositorSource,
13
+ CompositorSourceOptions,
14
+ FrameExportOptions,
15
+ PreviewOptions,
16
+ } from './types';
17
+ import { CompositorWorkerClient } from './worker-client';
18
+ import type { CompositorWorkerFrame, CompositorWorkerSourceInfo } from './worker-types';
19
+
20
+ interface CompositorState {
21
+ playing: boolean;
22
+ currentTime: number;
23
+ duration: number;
24
+ seeking: boolean;
25
+ }
26
+
27
+ // Pre-allocated arrays for render loop to reduce GC pressure
28
+ interface RenderBuffers {
29
+ visibleLayers: CompositorLayer[];
30
+ framePromises: Promise<CanvasImageSource | null>[];
31
+ frameImages: (CanvasImageSource | null)[];
32
+ }
33
+
34
+ /**
35
+ * Canvas-based video compositor for composing multiple media sources into a single output.
36
+ * Supports layered rendering with transforms, opacity, and rotation.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * const compositor = new Compositor({
41
+ * canvas: document.querySelector('canvas'),
42
+ * width: 1920,
43
+ * height: 1080
44
+ * });
45
+ *
46
+ * const source = await compositor.loadSource('video.mp4');
47
+ * compositor.preview({
48
+ * duration: 10,
49
+ * getComposition: (time) => ({
50
+ * time,
51
+ * layers: [{ source, transform: { opacity: 1 } }]
52
+ * })
53
+ * });
54
+ * compositor.play();
55
+ * ```
56
+ */
57
+ export class Compositor {
58
+ private canvas: HTMLCanvasElement | OffscreenCanvas;
59
+ private ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null;
60
+ private width: number;
61
+ private height: number;
62
+ private backgroundColor: string;
63
+ private sourcePool: SourcePool;
64
+ private audioManager: CompositorAudioManager | null = null;
65
+ private workerClient: CompositorWorkerClient | null = null;
66
+ private workerSources = new Map<string, CompositorSource>();
67
+ private workerAudioSources = new Map<string, CompositorSource>();
68
+ private emitter: EventEmitter<CompositorEventMap>;
69
+ private state: CompositorState;
70
+ private animationFrameId: number | null = null;
71
+ private lastFrameTime = 0;
72
+ private lastRenderTime = 0;
73
+ private previewOptions: PreviewOptions | null = null;
74
+ private disposed = false;
75
+
76
+ // Performance optimizations
77
+ private renderBuffers: RenderBuffers = { visibleLayers: [], framePromises: [], frameImages: [] };
78
+ private lastTimeUpdateEmit = 0;
79
+ private timeUpdateThrottleMs = 100; // ~10Hz
80
+ private renderPending = false;
81
+
82
+ // Audio state
83
+ private activeAudioSourceIds = new Set<string>();
84
+ private audioScratch = {
85
+ nextActiveSourceIds: new Set<string>(),
86
+ newSourceIds: [] as string[],
87
+ newSourceTimes: [] as number[],
88
+ };
89
+ private registeredAudioSources = new Set<string>();
90
+
91
+ /**
92
+ * Creates a new Compositor instance.
93
+ * @param options - Configuration options for the compositor
94
+ */
95
+ constructor(options: CompositorOptions) {
96
+ this.canvas = options.canvas;
97
+ this.width = options.width ?? (this.canvas.width || 1920);
98
+ this.height = options.height ?? (this.canvas.height || 1080);
99
+ this.backgroundColor = options.backgroundColor ?? '#000000';
100
+ this.emitter = new EventEmitter({ maxListeners: 50 });
101
+ this.state = {
102
+ playing: false,
103
+ currentTime: 0,
104
+ duration: 0,
105
+ seeking: false,
106
+ };
107
+
108
+ // Set canvas dimensions
109
+ this.canvas.width = this.width;
110
+ this.canvas.height = this.height;
111
+
112
+ const workerEnabled =
113
+ typeof options.worker === 'boolean'
114
+ ? options.worker
115
+ : options.worker
116
+ ? (options.worker.enabled ?? true)
117
+ : false;
118
+ const canUseWorker =
119
+ workerEnabled &&
120
+ typeof Worker !== 'undefined' &&
121
+ typeof OffscreenCanvas !== 'undefined' &&
122
+ typeof (this.canvas as HTMLCanvasElement).transferControlToOffscreen === 'function' &&
123
+ !(this.canvas instanceof OffscreenCanvas);
124
+
125
+ if (workerEnabled && !canUseWorker) {
126
+ throw new Error('Worker compositor requires HTMLCanvasElement, OffscreenCanvas, and Worker support');
127
+ }
128
+
129
+ this.sourcePool = new SourcePool();
130
+
131
+ if (canUseWorker) {
132
+ try {
133
+ this.workerClient = new CompositorWorkerClient({
134
+ canvas: this.canvas as HTMLCanvasElement,
135
+ width: this.width,
136
+ height: this.height,
137
+ backgroundColor: this.backgroundColor,
138
+ worker: options.worker ?? true,
139
+ });
140
+ } catch (err) {
141
+ console.warn('[Compositor] Worker initialization failed, falling back to main thread rendering:', err);
142
+ this.workerClient = null;
143
+ }
144
+ }
145
+
146
+ if (options.enableAudio !== false) {
147
+ this.audioManager = new CompositorAudioManager();
148
+ }
149
+
150
+ if (!this.workerClient) {
151
+ // Get 2D context
152
+ this.ctx = this.canvas.getContext('2d', {
153
+ alpha: false,
154
+ desynchronized: true,
155
+ }) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
156
+
157
+ if (!this.ctx) {
158
+ throw new Error('Failed to get 2D context for compositor canvas');
159
+ }
160
+
161
+ // Initial clear
162
+ this.clear();
163
+ }
164
+ }
165
+
166
+ // Source Management
167
+
168
+ /**
169
+ * Loads a video source into the compositor's source pool.
170
+ * @param source - Video source (URL, File, Blob, or MediaStream)
171
+ * @param options - Optional loading configuration
172
+ * @returns The loaded compositor source
173
+ */
174
+ async loadSource(source: MediaSource, options?: CompositorSourceOptions): Promise<CompositorSource> {
175
+ this.checkDisposed();
176
+ if (this.workerClient) {
177
+ const info = await this.workerClient.loadSource(source, options);
178
+ const proxy = this.createWorkerSource(info);
179
+ if (this.audioManager && info.hasAudio) {
180
+ await this.loadWorkerAudio(source, proxy.id);
181
+ }
182
+ this.emitter.emit('sourceloaded', { id: proxy.id, source: proxy });
183
+ return proxy;
184
+ }
185
+
186
+ const loaded = await this.sourcePool.loadVideo(source, options);
187
+
188
+ // Register audio with audio manager if available
189
+ this.registerSourceAudio(loaded);
190
+
191
+ this.emitter.emit('sourceloaded', { id: loaded.id, source: loaded });
192
+ return loaded;
193
+ }
194
+
195
+ /**
196
+ * Loads an image source into the compositor's source pool.
197
+ * @param source - Image source (URL, File, or Blob)
198
+ * @returns The loaded compositor source
199
+ */
200
+ async loadImage(source: string | Blob | File): Promise<CompositorSource> {
201
+ this.checkDisposed();
202
+ if (this.workerClient) {
203
+ const info = await this.workerClient.loadImage(source);
204
+ const proxy = this.createWorkerSource(info);
205
+ this.emitter.emit('sourceloaded', { id: proxy.id, source: proxy });
206
+ return proxy;
207
+ }
208
+
209
+ const loaded = await this.sourcePool.loadImage(source);
210
+ this.emitter.emit('sourceloaded', { id: loaded.id, source: loaded });
211
+ return loaded;
212
+ }
213
+
214
+ /**
215
+ * Loads an audio source into the compositor's source pool.
216
+ * @param source - Audio source (URL, File, Blob, or MediaStream)
217
+ * @param options - Optional loading configuration
218
+ * @returns The loaded compositor source
219
+ */
220
+ async loadAudio(source: MediaSource, options?: CompositorSourceOptions): Promise<CompositorSource> {
221
+ this.checkDisposed();
222
+ if (this.workerClient) {
223
+ const info = await this.workerClient.loadAudio(source, options);
224
+ const proxy = this.createWorkerSource(info);
225
+ if (this.audioManager) {
226
+ await this.loadWorkerAudio(source, proxy.id);
227
+ }
228
+ this.emitter.emit('sourceloaded', { id: proxy.id, source: proxy });
229
+ return proxy;
230
+ }
231
+
232
+ const loaded = await this.sourcePool.loadAudio(source, options);
233
+
234
+ // Register audio with audio manager
235
+ this.registerSourceAudio(loaded);
236
+
237
+ this.emitter.emit('sourceloaded', { id: loaded.id, source: loaded });
238
+ return loaded;
239
+ }
240
+
241
+ /**
242
+ * Unloads a source from the compositor's source pool.
243
+ * @param id - The source ID to unload
244
+ * @returns True if the source was found and unloaded
245
+ */
246
+ unloadSource(id: string): boolean {
247
+ if (this.workerClient) {
248
+ const source = this.workerSources.get(id);
249
+ if (!source) return false;
250
+ void this.workerClient.unloadSource(id);
251
+ this.workerSources.delete(id);
252
+ this.unloadWorkerAudio(id);
253
+ this.emitter.emit('sourceunloaded', { id });
254
+ return true;
255
+ }
256
+
257
+ // Unregister audio before unloading
258
+ if (this.registeredAudioSources.has(id)) {
259
+ this.audioManager?.unregisterSource(id);
260
+ this.registeredAudioSources.delete(id);
261
+ }
262
+
263
+ const result = this.sourcePool.unloadSource(id);
264
+ if (result) {
265
+ this.emitter.emit('sourceunloaded', { id });
266
+ }
267
+ return result;
268
+ }
269
+
270
+ /**
271
+ * Registers a source's audio with the audio manager.
272
+ */
273
+ private registerSourceAudio(source: CompositorSource): void {
274
+ if (!this.audioManager) return;
275
+ if (this.registeredAudioSources.has(source.id)) return;
276
+
277
+ const audioBufferSink = source.getAudioBufferSink?.();
278
+ if (audioBufferSink) {
279
+ this.audioManager.registerSource(source, audioBufferSink);
280
+ this.registeredAudioSources.add(source.id);
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Processes audio layers for the current frame.
286
+ */
287
+ private processAudioLayers(layers: AudioLayer[], mediaTime: number): void {
288
+ if (!this.audioManager) return;
289
+ const newSourceIds = this.audioScratch.newSourceIds;
290
+ const newSourceTimes = this.audioScratch.newSourceTimes;
291
+ const nextActiveSourceIds = this.audioScratch.nextActiveSourceIds;
292
+ const previousActiveSourceIds = this.activeAudioSourceIds;
293
+
294
+ newSourceIds.length = 0;
295
+ newSourceTimes.length = 0;
296
+ nextActiveSourceIds.clear();
297
+
298
+ for (let i = 0; i < layers.length; i++) {
299
+ const layer = layers[i];
300
+ if (layer.muted) continue;
301
+
302
+ const sourceId = layer.source.id;
303
+ if (!this.audioManager.hasSource(sourceId)) continue;
304
+
305
+ nextActiveSourceIds.add(sourceId);
306
+
307
+ if (!previousActiveSourceIds.has(sourceId)) {
308
+ newSourceIds.push(sourceId);
309
+ newSourceTimes.push(layer.sourceTime ?? mediaTime);
310
+ }
311
+ }
312
+
313
+ // Update active sources
314
+ if (previousActiveSourceIds.size > 0) {
315
+ previousActiveSourceIds.clear();
316
+ }
317
+ for (const sourceId of nextActiveSourceIds) {
318
+ previousActiveSourceIds.add(sourceId);
319
+ }
320
+
321
+ // Process layers with audio manager
322
+ this.audioManager.processAudioLayers(layers, mediaTime);
323
+
324
+ // Start playback for new sources
325
+ for (let i = 0; i < newSourceIds.length; i++) {
326
+ this.audioManager.startSourcePlayback(newSourceIds[i], newSourceTimes[i]);
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Gets a source by ID from the source pool.
332
+ * @param id - The source ID
333
+ * @returns The source if found, undefined otherwise
334
+ */
335
+ getSource(id: string): CompositorSource | undefined {
336
+ if (this.workerClient) {
337
+ return this.workerSources.get(id);
338
+ }
339
+ return this.sourcePool.getSource(id);
340
+ }
341
+
342
+ /**
343
+ * Gets all sources currently loaded in the source pool.
344
+ * @returns Array of all loaded sources
345
+ */
346
+ getAllSources(): CompositorSource[] {
347
+ if (this.workerClient) {
348
+ return Array.from(this.workerSources.values());
349
+ }
350
+ return this.sourcePool.getAllSources();
351
+ }
352
+
353
+ // Rendering
354
+
355
+ /**
356
+ * Renders a composition frame to the canvas.
357
+ * Fetches all layer frames in parallel before drawing to prevent flicker.
358
+ * @param frame - The composition frame to render
359
+ * @returns True if rendering succeeded
360
+ */
361
+ async render(frame: CompositionFrame): Promise<boolean> {
362
+ this.checkDisposed();
363
+ if (this.workerClient) {
364
+ const workerFrame = this.serializeWorkerFrame(frame);
365
+ return this.workerClient.render(workerFrame);
366
+ }
367
+ const ctx = this.ctx;
368
+ if (!ctx) return false;
369
+
370
+ // Reuse pre-allocated arrays
371
+ const { visibleLayers, framePromises, frameImages } = this.renderBuffers;
372
+ visibleLayers.length = 0;
373
+ framePromises.length = 0;
374
+ frameImages.length = 0;
375
+
376
+ // Filter visible layers into pre-allocated array, track sort order
377
+ let needsSort = false;
378
+ let lastZIndex = -Infinity;
379
+ const layers = frame.layers;
380
+ for (let i = 0; i < layers.length; i++) {
381
+ const layer = layers[i];
382
+ if (layer.visible === false) continue;
383
+
384
+ const zIndex = layer.zIndex ?? 0;
385
+ if (zIndex < lastZIndex) {
386
+ needsSort = true;
387
+ }
388
+ lastZIndex = zIndex;
389
+
390
+ visibleLayers.push(layer);
391
+ }
392
+
393
+ if (visibleLayers.length === 0) {
394
+ ctx.fillStyle = this.backgroundColor;
395
+ ctx.fillRect(0, 0, this.width, this.height);
396
+ return true;
397
+ }
398
+
399
+ if (needsSort) {
400
+ visibleLayers.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
401
+ }
402
+
403
+ // Fetch all frames in parallel (promises already in flight), store results densely
404
+ for (let i = 0; i < visibleLayers.length; i++) {
405
+ const layer = visibleLayers[i];
406
+ const sourceTime = layer.sourceTime ?? frame.time;
407
+ framePromises[i] = layer.source.getFrameAt(sourceTime);
408
+ }
409
+
410
+ const images = await Promise.all(framePromises);
411
+ for (let i = 0; i < images.length; i++) {
412
+ frameImages[i] = images[i] ?? null;
413
+ }
414
+
415
+ // Clear and render - synchronous to prevent flicker
416
+ ctx.fillStyle = this.backgroundColor;
417
+ ctx.fillRect(0, 0, this.width, this.height);
418
+
419
+ // Render in order, skip null entries (failed fetches)
420
+ for (let i = 0; i < visibleLayers.length; i++) {
421
+ const image = frameImages[i];
422
+ if (image) {
423
+ this.renderLayer(image, visibleLayers[i]);
424
+ }
425
+ }
426
+
427
+ return true;
428
+ }
429
+
430
+ private renderLayer(image: CanvasImageSource, layer: CompositorLayer): void {
431
+ const ctx = this.ctx;
432
+ if (!ctx) return;
433
+
434
+ const transform = layer.transform;
435
+ const sourceWidth = layer.source.width ?? this.width;
436
+ const sourceHeight = layer.source.height ?? this.height;
437
+
438
+ // Fast path: no transform object means draw at origin with source dimensions
439
+ if (!transform) {
440
+ ctx.drawImage(image, 0, 0, sourceWidth, sourceHeight);
441
+ return;
442
+ }
443
+
444
+ const destWidth = transform.width ?? sourceWidth;
445
+ const destHeight = transform.height ?? sourceHeight;
446
+ const x = transform.x ?? 0;
447
+ const y = transform.y ?? 0;
448
+ const rotation = transform.rotation ?? 0;
449
+ const scaleX = transform.scaleX ?? 1;
450
+ const scaleY = transform.scaleY ?? 1;
451
+ const opacity = transform.opacity ?? 1;
452
+
453
+ // Check if we need context state changes
454
+ const needsOpacity = opacity !== 1;
455
+ const needsTransform = rotation !== 0 || scaleX !== 1 || scaleY !== 1;
456
+
457
+ // Fast path: simple position/size only, no rotation/scale/opacity
458
+ if (!needsOpacity && !needsTransform) {
459
+ ctx.drawImage(image, x, y, destWidth, destHeight);
460
+ return;
461
+ }
462
+
463
+ const anchorX = transform.anchorX ?? 0.5;
464
+ const anchorY = transform.anchorY ?? 0.5;
465
+
466
+ // Save context state only when needed
467
+ ctx.save();
468
+
469
+ if (needsOpacity) {
470
+ ctx.globalAlpha = opacity;
471
+ }
472
+
473
+ // Move to layer position
474
+ ctx.translate(x + destWidth * anchorX, y + destHeight * anchorY);
475
+
476
+ if (rotation !== 0) {
477
+ ctx.rotate((rotation * Math.PI) / 180);
478
+ }
479
+
480
+ if (scaleX !== 1 || scaleY !== 1) {
481
+ ctx.scale(scaleX, scaleY);
482
+ }
483
+
484
+ // Draw image centered on anchor point
485
+ ctx.drawImage(image, -destWidth * anchorX, -destHeight * anchorY, destWidth, destHeight);
486
+
487
+ ctx.restore();
488
+ }
489
+
490
+ private createWorkerSource(info: CompositorWorkerSourceInfo): CompositorSource {
491
+ const proxy: CompositorSource = {
492
+ id: info.id,
493
+ type: info.type,
494
+ duration: info.duration,
495
+ width: info.width,
496
+ height: info.height,
497
+ async getFrameAt(): Promise<CanvasImageSource | null> {
498
+ throw new Error('getFrameAt is not available when worker rendering is enabled');
499
+ },
500
+ getAudioBufferSink(): import('mediabunny').AudioBufferSink | null {
501
+ return null;
502
+ },
503
+ hasAudio(): boolean {
504
+ return info.hasAudio ?? false;
505
+ },
506
+ dispose(): void {
507
+ // Managed by the compositor worker
508
+ },
509
+ };
510
+
511
+ this.workerSources.set(proxy.id, proxy);
512
+ return proxy;
513
+ }
514
+
515
+ private async loadWorkerAudio(source: MediaSource, id: string): Promise<void> {
516
+ if (!this.audioManager) return;
517
+
518
+ if (this.workerAudioSources.has(id)) {
519
+ this.unloadWorkerAudio(id);
520
+ }
521
+
522
+ try {
523
+ const audioSource = await this.sourcePool.loadAudio(source, { id });
524
+ this.workerAudioSources.set(id, audioSource);
525
+ this.registerSourceAudio(audioSource);
526
+ } catch {
527
+ // Ignore audio load failures in worker mode
528
+ }
529
+ }
530
+
531
+ private unloadWorkerAudio(id: string): void {
532
+ if (!this.audioManager) return;
533
+
534
+ if (this.workerAudioSources.has(id)) {
535
+ this.audioManager.unregisterSource(id);
536
+ this.registeredAudioSources.delete(id);
537
+ this.sourcePool.unloadSource(id);
538
+ this.workerAudioSources.delete(id);
539
+ }
540
+ }
541
+
542
+ private serializeWorkerFrame(frame: CompositionFrame): CompositorWorkerFrame {
543
+ if (!this.workerClient) {
544
+ throw new Error('Worker compositor not initialized');
545
+ }
546
+
547
+ const layers = frame.layers;
548
+ const serializedLayers = new Array(layers.length);
549
+ for (let i = 0; i < layers.length; i++) {
550
+ const layer = layers[i];
551
+ const sourceId = layer.source.id;
552
+ if (!this.workerSources.has(sourceId)) {
553
+ throw new Error(`Layer source ${sourceId} is not managed by this compositor`);
554
+ }
555
+ serializedLayers[i] = {
556
+ sourceId,
557
+ sourceTime: layer.sourceTime,
558
+ transform: layer.transform,
559
+ visible: layer.visible,
560
+ zIndex: layer.zIndex,
561
+ };
562
+ }
563
+
564
+ return {
565
+ time: frame.time,
566
+ layers: serializedLayers,
567
+ };
568
+ }
569
+
570
+ /**
571
+ * Clears the canvas with the background color.
572
+ */
573
+ clear(): void {
574
+ if (this.workerClient) {
575
+ void this.workerClient.clear();
576
+ return;
577
+ }
578
+ if (!this.ctx) return;
579
+ this.ctx.fillStyle = this.backgroundColor;
580
+ this.ctx.fillRect(0, 0, this.width, this.height);
581
+ }
582
+
583
+ // Preview Playback
584
+
585
+ /**
586
+ * Configures the preview playback with a composition callback.
587
+ * Must be called before play() or seek().
588
+ * @param options - Preview configuration including duration and composition callback
589
+ */
590
+ preview(options: PreviewOptions): void {
591
+ this.checkDisposed();
592
+ this.previewOptions = options;
593
+ this.state.duration = options.duration;
594
+ this.lastRenderTime = 0;
595
+ this.emitter.emit('compositionchange', undefined);
596
+ }
597
+
598
+ /**
599
+ * Starts playback of the preview composition.
600
+ * @throws Error if preview() has not been called first
601
+ */
602
+ async play(): Promise<void> {
603
+ this.checkDisposed();
604
+ if (this.state.playing) return;
605
+ if (!this.previewOptions) {
606
+ throw new Error('No preview configured. Call preview() first.');
607
+ }
608
+
609
+ this.state.playing = true;
610
+ this.lastFrameTime = performance.now();
611
+ this.lastRenderTime = this.lastFrameTime;
612
+ this.emitter.emit('play', undefined);
613
+
614
+ // Start audio playback
615
+ if (this.audioManager) {
616
+ // Reset active audio tracking so sources restart after pause/seek.
617
+ this.activeAudioSourceIds.clear();
618
+ await this.audioManager.play(this.state.currentTime);
619
+ }
620
+
621
+ // Start render loop
622
+ this.startRenderLoop();
623
+ }
624
+
625
+ /**
626
+ * Pauses playback of the preview composition.
627
+ */
628
+ pause(): void {
629
+ this.checkDisposed();
630
+ if (!this.state.playing) return;
631
+
632
+ this.state.playing = false;
633
+ this.stopRenderLoop();
634
+ if (this.audioManager) {
635
+ this.audioManager.pause();
636
+ }
637
+ this.emitter.emit('pause', undefined);
638
+ }
639
+
640
+ /**
641
+ * Seeks to a specific time in the preview composition.
642
+ * @param time - Time in seconds to seek to
643
+ */
644
+ async seek(time: number): Promise<void> {
645
+ this.checkDisposed();
646
+ if (!this.previewOptions) return;
647
+
648
+ const clampedTime = Math.max(0, Math.min(time, this.state.duration));
649
+ this.state.seeking = true;
650
+ this.emitter.emit('seeking', { time: clampedTime });
651
+
652
+ this.state.currentTime = clampedTime;
653
+
654
+ // Seek audio
655
+ if (this.audioManager) {
656
+ await this.audioManager.seek(clampedTime);
657
+ }
658
+
659
+ // Render frame at new time
660
+ const frame = this.previewOptions.getComposition(clampedTime);
661
+ await this.render(frame);
662
+
663
+ this.state.seeking = false;
664
+ this.emitter.emit('seeked', { time: clampedTime });
665
+ this.emitter.emit('timeupdate', { currentTime: clampedTime });
666
+ }
667
+
668
+ private startRenderLoop(): void {
669
+ if (this.animationFrameId !== null) return;
670
+
671
+ const tick = () => {
672
+ if (!this.state.playing || !this.previewOptions) return;
673
+
674
+ // Schedule next frame IMMEDIATELY to maintain consistent timing
675
+ this.animationFrameId = requestAnimationFrame(tick);
676
+
677
+ const now = performance.now();
678
+ const deltaTime = (now - this.lastFrameTime) / 1000;
679
+ this.lastFrameTime = now;
680
+
681
+ // Update current time
682
+ this.state.currentTime += deltaTime;
683
+
684
+ // Check for end
685
+ if (this.state.currentTime >= this.state.duration) {
686
+ if (this.previewOptions.loop) {
687
+ this.state.currentTime = 0;
688
+ } else {
689
+ this.state.currentTime = this.state.duration;
690
+ this.pause();
691
+ this.emitter.emit('ended', undefined);
692
+ return;
693
+ }
694
+ }
695
+
696
+ const fps = this.previewOptions.fps ?? 0;
697
+ const frameIntervalMs = fps > 0 ? 1000 / fps : 0;
698
+ const shouldRender =
699
+ !this.renderPending && (frameIntervalMs === 0 || now - this.lastRenderTime >= frameIntervalMs);
700
+
701
+ if (shouldRender) {
702
+ // Get composition and render (non-blocking)
703
+ this.renderPending = true;
704
+ this.lastRenderTime = now;
705
+
706
+ const frame = this.previewOptions.getComposition(this.state.currentTime);
707
+
708
+ // Process audio layers
709
+ if (this.audioManager) {
710
+ this.processAudioLayers(frame.audio ?? [], this.state.currentTime);
711
+ }
712
+
713
+ this.render(frame)
714
+ .catch(() => {
715
+ // Ignore render errors, will retry next frame
716
+ })
717
+ .finally(() => {
718
+ this.renderPending = false;
719
+ });
720
+ }
721
+
722
+ // Throttle timeupdate events to ~10Hz
723
+ if (now - this.lastTimeUpdateEmit >= this.timeUpdateThrottleMs) {
724
+ this.lastTimeUpdateEmit = now;
725
+ this.emitter.emit('timeupdate', { currentTime: this.state.currentTime });
726
+ }
727
+ };
728
+
729
+ this.animationFrameId = requestAnimationFrame(tick);
730
+ }
731
+
732
+ private stopRenderLoop(): void {
733
+ if (this.animationFrameId !== null) {
734
+ cancelAnimationFrame(this.animationFrameId);
735
+ this.animationFrameId = null;
736
+ }
737
+ }
738
+
739
+ // Frame Export
740
+
741
+ /**
742
+ * Exports a single frame at the specified time as an image blob.
743
+ * @param time - Time in seconds to export
744
+ * @param options - Export options (format, quality)
745
+ * @returns Image blob or null if export failed
746
+ */
747
+ async exportFrame(time: number, options: FrameExportOptions = {}): Promise<Blob | null> {
748
+ this.checkDisposed();
749
+ if (!this.previewOptions) return null;
750
+
751
+ // Render frame at specified time
752
+ const frame = this.previewOptions.getComposition(time);
753
+
754
+ if (this.workerClient) {
755
+ const workerFrame = this.serializeWorkerFrame(frame);
756
+ return this.workerClient.exportFrame(workerFrame, options);
757
+ }
758
+
759
+ await this.render(frame);
760
+
761
+ // Export canvas to blob
762
+ if ('toBlob' in this.canvas) {
763
+ return new Promise((resolve) => {
764
+ (this.canvas as HTMLCanvasElement).toBlob(
765
+ (blob) => resolve(blob),
766
+ `image/${options.format ?? 'png'}`,
767
+ options.quality
768
+ );
769
+ });
770
+ } else {
771
+ return (this.canvas as OffscreenCanvas).convertToBlob({
772
+ type: `image/${options.format ?? 'png'}`,
773
+ quality: options.quality,
774
+ });
775
+ }
776
+ }
777
+
778
+ // State Getters
779
+
780
+ /** Current playback time in seconds. */
781
+ get currentTime(): number {
782
+ return this.state.currentTime;
783
+ }
784
+
785
+ /** Total duration of the preview composition in seconds. */
786
+ get duration(): number {
787
+ return this.state.duration;
788
+ }
789
+
790
+ /** Whether the compositor is currently playing. */
791
+ get playing(): boolean {
792
+ return this.state.playing;
793
+ }
794
+
795
+ /** Whether the compositor is currently paused. */
796
+ get paused(): boolean {
797
+ return !this.state.playing;
798
+ }
799
+
800
+ /** Whether the compositor is currently seeking. */
801
+ get seeking(): boolean {
802
+ return this.state.seeking;
803
+ }
804
+
805
+ /**
806
+ * Gets the current canvas width.
807
+ * @returns Width in pixels
808
+ */
809
+ getWidth(): number {
810
+ return this.width;
811
+ }
812
+
813
+ /**
814
+ * Gets the current canvas height.
815
+ * @returns Height in pixels
816
+ */
817
+ getHeight(): number {
818
+ return this.height;
819
+ }
820
+
821
+ /**
822
+ * Resizes the compositor canvas without disposing loaded sources.
823
+ * @param width - New width in pixels
824
+ * @param height - New height in pixels
825
+ */
826
+ resize(width: number, height: number): void {
827
+ this.checkDisposed();
828
+ this.width = width;
829
+ this.height = height;
830
+ this.canvas.width = width;
831
+ this.canvas.height = height;
832
+ if (this.workerClient) {
833
+ void this.workerClient.resize(width, height);
834
+ return;
835
+ }
836
+ this.clear();
837
+ }
838
+
839
+ // Events
840
+
841
+ /**
842
+ * Subscribes to a compositor event.
843
+ * @param event - Event name to listen for
844
+ * @param listener - Callback function
845
+ * @returns Unsubscribe function
846
+ */
847
+ on<K extends keyof CompositorEventMap>(event: K, listener: CompositorEventListener<K>): () => void {
848
+ return this.emitter.on(event, listener);
849
+ }
850
+
851
+ /**
852
+ * Subscribes to a compositor event for a single invocation.
853
+ * @param event - Event name to listen for
854
+ * @param listener - Callback function
855
+ * @returns Unsubscribe function
856
+ */
857
+ once<K extends keyof CompositorEventMap>(event: K, listener: CompositorEventListener<K>): () => void {
858
+ return this.emitter.once(event, listener);
859
+ }
860
+
861
+ /**
862
+ * Unsubscribes from a compositor event.
863
+ * @param event - Event name to unsubscribe from
864
+ * @param listener - Optional specific listener to remove
865
+ */
866
+ off<K extends keyof CompositorEventMap>(event: K, listener?: CompositorEventListener<K>): void {
867
+ this.emitter.off(event, listener);
868
+ }
869
+
870
+ // Audio Control
871
+
872
+ /**
873
+ * Sets the master volume for all audio layers.
874
+ * @param volume - Volume level (0 to 1)
875
+ */
876
+ setVolume(volume: number): void {
877
+ this.audioManager?.setMasterVolume(volume);
878
+ }
879
+
880
+ /**
881
+ * Sets the master mute state for all audio layers.
882
+ * @param muted - Whether audio is muted
883
+ */
884
+ setMuted(muted: boolean): void {
885
+ this.audioManager?.setMasterMuted(muted);
886
+ }
887
+
888
+ /**
889
+ * Gets the audio context used by the compositor.
890
+ * Useful for advanced audio processing.
891
+ */
892
+ getAudioContext(): AudioContext {
893
+ if (!this.audioManager) {
894
+ throw new Error('Audio is disabled for this compositor');
895
+ }
896
+ return this.audioManager.getAudioContext();
897
+ }
898
+
899
+ // Lifecycle
900
+
901
+ private checkDisposed(): void {
902
+ if (this.disposed) {
903
+ throw new Error('Compositor has been disposed');
904
+ }
905
+ }
906
+
907
+ /**
908
+ * Disposes the compositor and releases all resources.
909
+ * After disposal, the compositor cannot be used.
910
+ */
911
+ dispose(): void {
912
+ if (this.disposed) return;
913
+ this.disposed = true;
914
+
915
+ this.stopRenderLoop();
916
+ this.audioManager?.dispose();
917
+ this.workerClient?.dispose();
918
+ this.workerClient = null;
919
+ this.sourcePool.dispose();
920
+ this.registeredAudioSources.clear();
921
+ this.activeAudioSourceIds.clear();
922
+ this.audioScratch.nextActiveSourceIds.clear();
923
+ this.audioScratch.newSourceIds.length = 0;
924
+ this.audioScratch.newSourceTimes.length = 0;
925
+ this.workerSources.clear();
926
+ this.workerAudioSources.clear();
927
+ this.emitter.removeAllListeners();
928
+ this.ctx = null;
929
+ this.previewOptions = null;
930
+ }
931
+ }