import { BufferTarget, CanvasSource, Mp4OutputFormat, Output, Quality, WebMOutputFormat, canEncodeVideo, type VideoEncodingConfig, } from "mediabunny"; import type { SceneSnapshot } from "./types"; import { createRenderer } from "./renderer"; import { fitModel } from "./framing"; const FPS = 30; export function videoDimensions(size: number, aspect: string) { const longEdge = Math.round((size * 16) / 9 / 2) * 2; return { width: aspect === "landscape" ? longEdge : size, height: aspect === "portrait" ? longEdge : size, }; } export function framePlan( duration: number, turns: number, fps = 30, direction: 1 | -1 = 1, ) { const frames = Math.round(duration * turns * fps); return { frames, duration: frames / fps, at: (i: number) => ({ timestamp: i / fps, duration: 1 / fps, angle: ((direction * i) / frames) * turns * Math.PI * 2, }), }; } export async function exportVideo( scene: SceneSnapshot, options: { size: number; aspect: string; format: string; turns: number; }, signal: AbortSignal, onProgress: (p: number) => void, ) { const { model, settings } = scene; if (typeof VideoEncoder === "undefined") throw Error( "Video export needs a browser with WebCodecs. Try a current version of Chrome or Edge.", ); const { width, height } = videoDimensions(options.size, options.aspect); const codec = options.format === "webm" ? "vp9" : "avc"; // Fine particles need constant detail, not a fixed budget shared by every size. // Mediabunny tries quantizer mode first, then this resolution-scaled bitrate. const encoding: VideoEncodingConfig = { codec, quality: new Quality({ quantizer: codec === "vp9" ? 16 : 12, bitrate: Math.max(16_000_000, Math.round(width * height * FPS * 0.8)), bitrateMode: "variable", }), latencyMode: "quality", contentHint: "detail", keyFrameInterval: 2, }; if (!(await canEncodeVideo(codec, { ...encoding, width, height }))) throw Error( `${options.format === "mp4" ? "MP4 / H.264" : "WebM / VP9"} encoding is unavailable in this browser. Try the other format or a lower resolution.`, ); signal.throwIfAborted(); const renderer = await createRenderer(scene.backend); let deviceError = ""; renderer.onError = (message) => { deviceError = message; }; const output = new Output({ format: options.format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat({ fastStart: "in-memory" }), target: new BufferTarget(), }); const plan = framePlan( settings.duration, options.turns, FPS, model.rotationDirection, ); try { // Keep the live scene's pixel density, particle footprints, and wire rasterization. // Resize the complete image once, instead of changing its look at each resolution. renderer.resize(scene.width, scene.height, scene.pixelRatio); const fit = fitModel(model, settings, width, height); const sourceSize = Math.min(renderer.canvas.width, renderer.canvas.height) * scene.frame.scale; const scale = (Math.min(width, height) * fit) / sourceSize; const cropWidth = width / scale, cropHeight = height / scale; const cropX = renderer.canvas.width * scene.frame.x - cropWidth / 2; const cropY = renderer.canvas.height * scene.frame.y - cropHeight / 2; const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d", { alpha: false, colorSpace: "srgb", }); if (!context) throw Error("Could not prepare the video canvas."); context.imageSmoothingEnabled = true; context.imageSmoothingQuality = "high"; context.fillStyle = "rgb(5,6,8)"; const source = new CanvasSource(canvas, encoding); output.addVideoTrack(source, { frameRate: FPS }); await output.start(); for (let i = 0; i < plan.frames; i++) { signal.throwIfAborted(); if (deviceError) throw Error(deviceError); const frame = plan.at(i); renderer.draw(model, settings, scene.angle + frame.angle, scene.frame); // Snapshot in the same task as the draw, before a WebGPU canvas may be presented. context.fillRect(0, 0, width, height); context.drawImage( renderer.canvas, cropX, cropY, cropWidth, cropHeight, 0, 0, width, height, ); await source.add(frame.timestamp, frame.duration); onProgress((i + 1) / (plan.frames + 1)); if (i % 6 === 0) await new Promise((resolve) => setTimeout(resolve, 0)); } source.close(); signal.throwIfAborted(); await output.finalize(); signal.throwIfAborted(); const buffer = output.target.buffer; if (!buffer?.byteLength) throw Error("The encoder did not produce a video."); onProgress(1); return { blob: new Blob([buffer], { type: options.format === "webm" ? "video/webm" : "video/mp4", }), extension: options.format === "webm" ? "webm" : "mp4", }; } catch (e) { await output.cancel().catch(() => {}); throw e; } finally { renderer.dispose(); } }