Spaces:
Running
Running
Download src/lib/export.ts from Xenova/kinetic-depth-effect: direct link, hf CLI and curl.
- Browser
- Download file 5.26 kB
-
https://huggingface.co/spaces/Xenova/kinetic-depth-effect/resolve/main/src/lib/export.ts
- Command line
-
hf download hf://spaces/Xenova/kinetic-depth-effect/src/lib/export.ts
-
curl -L -o export.ts https://huggingface.co/spaces/Xenova/kinetic-depth-effect/resolve/main/src/lib/export.ts
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(); | |
| } | |
| } | |