import { useMemo, useRef, useState } from "react"; import type { AudioPrep } from "@plate-dyn/core"; import { movieSpan, movieSupport, recordMovie, saveMovie, type MovieOverlay, type MovieResult, type MovieSource, } from "./recordMovie"; export interface MovieExportProps { source: MovieSource; /** Simulated time of every frame [s] */ frameTimes: Float64Array; audio?: AudioPrep; overlay?: MovieOverlay; /** Height of the scene as a fraction of the width */ aspect?: number; width?: number; /** Base name of the downloaded file, without extension */ filename?: string; /** Frame to leave on screen when the recording ends */ restoreFrame?: number; /** Told when the recording starts and stops, so the caller can pause its player */ onRecording?: (recording: boolean) => void; } const SPEEDS = [ { value: 1, label: "real time, with the sound" }, { value: 0.5, label: "half speed, with the sound" }, { value: 0.25, label: "quarter speed, with the sound" }, { value: 0, label: "slow motion of the frames, no sound" }, ]; /** Frames per second of the silent slow-motion movie */ const SLOW_FPS = 30; type State = | { status: "idle" } | { status: "recording"; fraction: number } | { status: "done"; result: MovieResult; speed: number } | { status: "error"; message: string }; /** * Records the scene as it stands, at its present orientation and colouring, * with the radiated sound under it. The recording is real time, so it takes as * long as the movie lasts; below real time the sound is slowed with the * picture and drops in pitch, as a tape would. */ export function MovieExport(props: MovieExportProps) { const { frameTimes, audio, overlay, aspect, width, filename = "plate", onRecording } = props; const support = useMemo(() => movieSupport(), []); const [speed, setSpeed] = useState(1); const [state, setState] = useState({ status: "idle" }); const sourceRef = useRef(props.source); sourceRef.current = props.source; const restoreRef = useRef(props.restoreFrame ?? 0); restoreRef.current = props.restoreFrame ?? 0; const abort = useRef(null); // Slow motion plays the frames one after another at SLOW_FPS without the // sound, which could not be slowed a thousandfold; the strike transient of a // few milliseconds then lasts a few seconds on screen. const slow = speed === 0; const frameSpan = movieSpan(frameTimes); const slowSpeed = frameSpan > 0 ? frameSpan / (frameTimes.length / SLOW_FPS) : 1; const effectiveSpeed = slow ? slowSpeed : speed; const effectiveAudio = slow ? undefined : audio; const span = useMemo(() => movieSpan(frameTimes, effectiveAudio), [frameTimes, effectiveAudio]); const length = span / effectiveSpeed; const recording = state.status === "recording"; // The recorder draws on the display's own refresh, so a run whose frames // come faster than that loses some of them; a slower speed spreads them out. const fps = length > 0 ? frameTimes.length / length : 0; const crowded = fps > 60; const start = async () => { if (recording || support.status !== "available") return; const controller = new AbortController(); abort.current = controller; setState({ status: "recording", fraction: 0 }); onRecording?.(true); try { const result = await recordMovie({ source: sourceRef.current, frameTimes, audio: effectiveAudio, overlay, aspect, width, speed: effectiveSpeed, restoreFrame: restoreRef.current, signal: controller.signal, onProgress: (fraction) => setState({ status: "recording", fraction }), }); saveMovie( result, `${filename}-${slow ? "slow-motion" : speed === 1 ? "realtime" : `${speed}x`}`, ); setState({ status: "done", result, speed }); } catch (e) { setState({ status: "error", message: e instanceof Error ? e.message : String(e) }); } finally { abort.current = null; onRecording?.(false); } }; return (
{recording && ( <>
recording in real time, {Math.max(0, length * (1 - state.fraction)).toFixed(1)} s left; keep this tab in front )} {!recording && support.status !== "available" && ( Not available here ({support.reason}). )} {!recording && support.status === "available" && state.status !== "done" && ( {length.toFixed(1)} s of {support.ext.toUpperCase()} {slow ? `: the ${frameTimes.length} frames of the first ${(frameSpan * 1e3).toPrecision(3)} ms at ${SLOW_FPS} a second, silent` : " with the sound, written in real time"} {speed === 1 || slow ? "" : "; the sound is slowed with the picture and drops in pitch"}. {crowded ? ` The run's ${frameTimes.length} frames would need ${fps.toFixed(0)} a second here, more than the display refreshes: choose a slower speed to keep them all.` : ""} )} {!recording && state.status === "done" && ( Saved {state.result.seconds.toFixed(1)} s, {state.result.width} × {state.result.height},{" "} {(state.result.blob.size / 1e6).toFixed(1)} MB, {state.result.drawn} of{" "} {state.result.frames} frames drawn {state.result.drawn < state.result.frames ? " (the rest were dropped to keep time)" : ""}. )} {state.status === "error" && {state.message}}
); }