Xenova's picture
Xenova HF Staff
Upload 63 files
f323fc1 verified
Raw History Blame Contribute Delete
5.26 kB
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<void>((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();
}
}