1import { useMemo, useRef, useState } from "react";
2import type { AudioPrep } from "@plate-dyn/core";
3import {
4 movieSpan,
5 movieSupport,
6 recordMovie,
7 saveMovie,
8 type MovieOverlay,
9 type MovieResult,
10 type MovieSource,
11} from "./recordMovie";
13export interface MovieExportProps {
14 source: MovieSource;
15 /** Simulated time of every frame [s] */
16 frameTimes: Float64Array;
17 audio?: AudioPrep;
18 overlay?: MovieOverlay;
19 /** Height of the scene as a fraction of the width */
20 aspect?: number;
21 width?: number;
22 /** Base name of the downloaded file, without extension */
23 filename?: string;
24 /** Frame to leave on screen when the recording ends */
25 restoreFrame?: number;
26 /** Told when the recording starts and stops, so the caller can pause its player */
27 onRecording?: (recording: boolean) => void;
28}
30const SPEEDS = [
31 { value: 1, label: "real time, with the sound" },
32 { value: 0.5, label: "half speed, with the sound" },
33 { value: 0.25, label: "quarter speed, with the sound" },
34 { value: 0, label: "slow motion of the frames, no sound" },
35];
37/** Frames per second of the silent slow-motion movie */
38const SLOW_FPS = 30;
40type State =
41 | { status: "idle" }
42 | { status: "recording"; fraction: number }
43 | { status: "done"; result: MovieResult; speed: number }
44 | { status: "error"; message: string };
46/**
47 * Records the scene as it stands, at its present orientation and colouring,
48 * with the radiated sound under it. The recording is real time, so it takes as
49 * long as the movie lasts; below real time the sound is slowed with the
50 * picture and drops in pitch, as a tape would.
51 */
52export function MovieExport(props: MovieExportProps) {
53 const { frameTimes, audio, overlay, aspect, width, filename = "plate", onRecording } = props;
54 const support = useMemo(() => movieSupport(), []);
55 const [speed, setSpeed] = useState(1);
56 const [state, setState] = useState<State>({ status: "idle" });
57 const sourceRef = useRef(props.source);
58 sourceRef.current = props.source;
59 const restoreRef = useRef(props.restoreFrame ?? 0);
60 restoreRef.current = props.restoreFrame ?? 0;
61 const abort = useRef<AbortController | null>(null);
63 // Slow motion plays the frames one after another at SLOW_FPS without the
64 // sound, which could not be slowed a thousandfold; the strike transient of a
65 // few milliseconds then lasts a few seconds on screen.
66 const slow = speed === 0;
67 const frameSpan = movieSpan(frameTimes);
68 const slowSpeed = frameSpan > 0 ? frameSpan / (frameTimes.length / SLOW_FPS) : 1;
69 const effectiveSpeed = slow ? slowSpeed : speed;
70 const effectiveAudio = slow ? undefined : audio;
71 const span = useMemo(() => movieSpan(frameTimes, effectiveAudio), [frameTimes, effectiveAudio]);
72 const length = span / effectiveSpeed;
73 const recording = state.status === "recording";
74 // The recorder draws on the display's own refresh, so a run whose frames
75 // come faster than that loses some of them; a slower speed spreads them out.
76 const fps = length > 0 ? frameTimes.length / length : 0;
77 const crowded = fps > 60;
79 const start = async () => {
80 if (recording || support.status !== "available") return;
81 const controller = new AbortController();
82 abort.current = controller;
83 setState({ status: "recording", fraction: 0 });
84 onRecording?.(true);
85 try {
86 const result = await recordMovie({
87 source: sourceRef.current,
88 frameTimes,
89 audio: effectiveAudio,
90 overlay,
91 aspect,
92 width,
93 speed: effectiveSpeed,
94 restoreFrame: restoreRef.current,
95 signal: controller.signal,
96 onProgress: (fraction) => setState({ status: "recording", fraction }),
97 });
98 saveMovie(
99 result,
100 `${filename}-${slow ? "slow-motion" : speed === 1 ? "realtime" : `${speed}x`}`,
101 );
102 setState({ status: "done", result, speed });
103 } catch (e) {
104 setState({ status: "error", message: e instanceof Error ? e.message : String(e) });
105 } finally {
106 abort.current = null;
107 onRecording?.(false);
108 }
109 };
111 return (
112 <div className="pl-movie">
113 <button
114 className="pl-btn"
115 onClick={() => void start()}
116 disabled={recording || support.status !== "available" || frameTimes.length < 2}
117 title="Record the scene as it is oriented"
118 >
119 Save movie
120 </button>
121 <label className="pl-muted">
122 speed{" "}
123 <select
124 className="pl-select"
125 style={{ width: "auto" }}
126 value={speed}
127 disabled={recording}
128 onChange={(e) => setSpeed(Number(e.target.value))}
129 >
130 {SPEEDS.map((s) => (
131 <option key={s.value} value={s.value}>
132 {s.label}
133 </option>
134 ))}
135 </select>
136 </label>
137 {recording && (
138 <>
139 <div className="pl-bar" aria-label="recording progress">
140 <div style={{ width: `${Math.round(state.fraction * 100)}%` }} />
141 </div>
142 <button className="pl-btn pl-btn-danger" onClick={() => abort.current?.abort()}>
143 Stop
144 </button>
145 <span className="pl-muted">
146 recording in real time, {Math.max(0, length * (1 - state.fraction)).toFixed(1)} s left;
147 keep this tab in front
148 </span>
149 </>
150 )}
151 {!recording && support.status !== "available" && (
152 <span className="pl-muted">Not available here ({support.reason}).</span>
153 )}
154 {!recording && support.status === "available" && state.status !== "done" && (
155 <span className="pl-muted">
156 {length.toFixed(1)} s of {support.ext.toUpperCase()}
157 {slow
158 ? `: the ${frameTimes.length} frames of the first ${(frameSpan * 1e3).toPrecision(3)} ms at ${SLOW_FPS} a second, silent`
159 : " with the sound, written in real time"}
160 {speed === 1 || slow ? "" : "; the sound is slowed with the picture and drops in pitch"}.
161 {crowded
162 ? ` 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.`
163 : ""}
164 </span>
165 )}
166 {!recording && state.status === "done" && (
167 <span className="pl-muted">
168 Saved {state.result.seconds.toFixed(1)} s, {state.result.width} × {state.result.height},{" "}
169 {(state.result.blob.size / 1e6).toFixed(1)} MB, {state.result.drawn} of{" "}
170 {state.result.frames} frames drawn
171 {state.result.drawn < state.result.frames ? " (the rest were dropped to keep time)" : ""}.
172 </span>
173 )}
174 {state.status === "error" && <span className="pl-issue">{state.message}</span>}
175 </div>
176 );
177}