Skip to content
react-wrapped

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.

No file made yet.

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.

download-video.tsx
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.

KeyTypeWhat it does
stageHTMLElementThe stage element itself, at the video's real resolution.
secondsnumberHow long the video plays at the speed it is set to.
show(seconds: number) => voidPuts that moment of the video on the stage.
intact() => booleanFalse once the video was rebuilt under the render: a setting changed, a scene joined.
release() => voidGives the stage back. The player returns to where it was and plays on if it was playing.

renderVideo

ts
function renderVideo(
  take: StageTake,
  size: { width: number; height: number },
  options: { onProgress: (share: number) => void; signal: AbortSignal },
): Promise<Blob>;
ArgumentWhat it is
takeWhat player.take() returned.
sizeThe frame in pixels. WRAPPED_FORMATS[config.format] has the right numbers: 1080 by 1920, 1080 by 1080 or 1920 by 1080.
onProgressCalled after every frame with the share done, from 0 to 1.
signalAn AbortSignal. Aborting it stops the render and rejects the promise.

Frame by frame

  • VIDEO_FPS is 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.

Last updated . Read this page as Markdown.