import EventEmitter from "eventemitter3"; import { Output, Mp4OutputFormat, WebMOutputFormat, BufferTarget, CanvasSource, AudioBufferSource, QUALITY_LOW, QUALITY_MEDIUM, QUALITY_HIGH, QUALITY_VERY_HIGH, } from "mediabunny"; import type { RootNode } from "./nodes/root-node"; import { CanvasRenderer } from "./canvas-renderer"; export type ExportFormat = "mp4" | "webm"; export type ExportQuality = "low" | "medium" | "high" | "very_high"; type ExportParams = { width: number; height: number; fps: number; format: ExportFormat; quality: ExportQuality; includeAudio?: boolean; audioBuffer?: AudioBuffer; }; const qualityMap = { low: QUALITY_LOW, medium: QUALITY_MEDIUM, high: QUALITY_HIGH, very_high: QUALITY_VERY_HIGH, }; export type SceneExporterEvents = { progress: [progress: number]; complete: [buffer: ArrayBuffer]; error: [error: Error]; cancelled: []; }; export class SceneExporter extends EventEmitter { private renderer: CanvasRenderer; private format: ExportFormat; private quality: ExportQuality; private includeAudio: boolean; private audioBuffer?: AudioBuffer; private cancelled = false; constructor(params: ExportParams) { super(); this.renderer = new CanvasRenderer({ width: params.width, height: params.height, fps: params.fps, }); this.format = params.format; this.quality = params.quality; this.includeAudio = params.includeAudio ?? false; this.audioBuffer = params.audioBuffer; } cancel() { this.cancelled = true; } async export(rootNode: RootNode) { const { fps } = this.renderer; const frameCount = Math.ceil(rootNode.duration * fps); const outputFormat = this.format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat(); const output = new Output({ format: outputFormat, target: new BufferTarget(), }); const videoSource = new CanvasSource(this.renderer.canvas, { codec: this.format === "webm" ? "vp9" : "avc", bitrate: qualityMap[this.quality], }); output.addVideoTrack(videoSource, { frameRate: fps }); // Add audio track if requested let audioSource: AudioBufferSource | null = null; if (this.includeAudio && this.audioBuffer) { audioSource = new AudioBufferSource({ codec: this.format === "webm" ? "opus" : "aac", bitrate: qualityMap[this.quality], }); output.addAudioTrack(audioSource); } await output.start(); // Add audio data after starting if (audioSource && this.audioBuffer) { await audioSource.add(this.audioBuffer); audioSource.close(); } // Render video frames for (let i = 0; i < frameCount; i++) { if (this.cancelled) { await output.cancel(); this.emit("cancelled"); return null; } const time = i / fps; await this.renderer.render({ node: rootNode, time }); await videoSource.add(time, 1 / fps); this.emit("progress", i / frameCount); } if (this.cancelled) { await output.cancel(); this.emit("cancelled"); return null; } videoSource.close(); await output.finalize(); this.emit("progress", 1); const buffer = output.target.buffer; if (!buffer) { this.emit("error", new Error("Failed to export video")); return null; } this.emit("complete", buffer); return buffer; } }