Video export
renderVideo turns the video on the page into an MP4 file, in the browser, with no server. It is not a screen recording: every frame is the real stage at the video's full resolution, set to its exact moment and handed to the browser's own H.264 encoder. Use it behind a download button.
Two scenes, to keep the wait short.
Usage
Ask the player's handle for a take, hand it to renderVideo with the size of the format, and save the blob that comes back. Always release the take, whether the render finished, failed or was stopped.
import { type CSSProperties, useEffect, useRef, useState } from "react";
import { type SceneInstance, WRAPPED_FORMATS, type WrappedConfig, type WrappedSubject } from "@react-wrapped/core";
import { frameTimes, renderVideo, VIDEO_FPS, WrappedPlayer, type WrappedPlayerHandle } from "@react-wrapped/player";
const ROOM: CSSProperties = { display: "grid", height: 560 };
export function DownloadVideo({
subject,
seed,
scenes,
config,
fileName,
}: {
subject: WrappedSubject;
seed: string;
scenes: readonly SceneInstance[];
config: WrappedConfig;
/** The saved file's name, extension included. */
fileName: string;
}) {
const player = useRef<WrappedPlayerHandle>(null);
const stop = useRef<AbortController | null>(null);
const [percent, setPercent] = useState<number | null>(null);
const [said, setSaid] = useState("No file made yet.");
// Leaving the page stops a video that is still being made.
useEffect(() => () => stop.current?.abort(), []);
const download = async () => {
// The stage and the playhead, on loan. The player stands still until `release` gives them back.
const take = player.current?.take();
if (!take) return;
const controller = new AbortController();
stop.current = controller;
setPercent(0);
setSaid(`Drawing ${frameTimes(take.seconds, VIDEO_FPS).length} frames, one by one.`);
try {
const video = await renderVideo(take, WRAPPED_FORMATS[config.format], {
signal: controller.signal,
// Called once a frame. Whole percents, so the page renders a hundred times and not six thousand.
onProgress: (share) => setPercent(Math.floor(share * 100)),
});
const url = URL.createObjectURL(video);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
setSaid(`Saved ${(video.size / 1_000_000).toFixed(1)} MB as ${fileName}.`);
} catch (error) {
// Stopped on purpose is not a failure. Anything else comes with a sentence a person can read.
if (controller.signal.aborted) setSaid("Stopped.");
else setSaid(error instanceof Error ? error.message : "The video did not come through. Try again.");
} finally {
take.release();
stop.current = null;
setPercent(null);
}
};
return (
<div>
{/* While the stage is being filmed nothing else may move it. */}
<div style={ROOM} inert={percent !== null}>
<WrappedPlayer ref={player} subject={subject} seed={seed} scenes={scenes} config={config} />
</div>
<div role="group" aria-label="Video file">
{percent === null ? (
<button type="button" onClick={() => void download()}>
Download the MP4
</button>
) : (
<button type="button" onClick={() => stop.current?.abort()}>
Stop at {percent}%
</button>
)}
</div>
<output>{said}</output>
</div>
);
}How take() lends the stage
take() on the player's handle pauses the video and hands over the stage and the playhead. While the take is out the render owns the frame: it calls show(seconds) for each frame's moment and draws what is there. release() puts the playhead back where it was and plays on if the video was playing.
| Key | Type | What it does |
|---|---|---|
stage | HTMLElement | The stage element itself, at the video's real resolution. |
seconds | number | How long the video plays at the speed it is set to. |
show | (seconds: number) => void | Puts that moment of the video on the stage. |
intact | () => boolean | False once the video was rebuilt under the render: a setting changed, a scene joined. |
release | () => void | Gives the stage back. The player returns to where it was and plays on if it was playing. |
renderVideo
function renderVideo(
take: StageTake,
size: { width: number; height: number },
options: { onProgress: (share: number) => void; signal: AbortSignal },
): Promise<Blob>;| Argument | What it is |
|---|---|
take | What player.take() returned. |
size | The frame in pixels. WRAPPED_FORMATS[config.format] has the right numbers: 1080 by 1920, 1080 by 1080 or 1920 by 1080. |
onProgress | Called after every frame with the share done, from 0 to 1. |
signal | An AbortSignal. Aborting it stops the render and rejects the promise. |
Frame by frame
VIDEO_FPSis 60.frameTimes(seconds, fps)lists the moment of every frame, and the last one is the very end of the video, so the file stops on the finished last scene.- A frame is the stage's own markup drawn onto a canvas. The style rules and fonts it needs are collected once and travel with every frame.
- The file is H.264 in an MP4, about 12 megabits a second at 1080p, with the hardware encoder when the machine has one.
- Speed counts: a video set to 1.5x makes a shorter file.
- The result does not depend on how big the player is on the page, how fast the machine is, or whether the tab is in front.
When it cannot be made
renderVideo rejects with a sentence you can show as it is. A browser without an H.264 encoder gets "This browser cannot make the video. Chrome, Edge and Safari can." A video that changed under the render gets "The video changed while it was being made. Try again." An aborted render rejects too: check signal.aborted before treating it as a failure.
Questions
- Does the export need a server?
- No. Every frame is drawn and encoded in the browser, and the file is a blob the page saves itself. Nothing is uploaded.
- Why is it slower than real time on some machines?
- Because it is not a recording. A 40 second video is 2,400 frames, each drawn at full size. A fast machine makes the file quicker than the video plays and a slow one takes longer, but the file is the same.
