/ proof-of-concept / plate-dynamics-lab
Sign in
proof-of-concept / plate-dynamics-lab
plate-dynamics-lab / packages / components / src / video / MovieExport.tsx
160 lines · 5.7 KBBlameHistoryRaw
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;
30const SPEEDS = [
31 { value: 1, label: "real time" },
32 { value: 0.5, label: "half speed" },
33 { value: 0.25, label: "quarter speed" },
34 { value: 0.1, label: "a tenth of speed" },
35];
37type State =
38 | { status: "idle" }
39 | { status: "recording"; fraction: number }
40 | { status: "done"; result: MovieResult; speed: number }
41 | { status: "error"; message: string };
43/**
44 * Records the scene as it stands, at its present orientation and colouring,
45 * with the radiated sound under it. The recording is real time, so it takes as
46 * long as the movie lasts; below real time the sound is slowed with the
47 * picture and drops in pitch, as a tape would.
48 */
49export function MovieExport(props: MovieExportProps) {
50 const { frameTimes, audio, overlay, aspect, width, filename = "plate", onRecording } = props;
51 const support = useMemo(() => movieSupport(), []);
52 const [speed, setSpeed] = useState(1);
53 const [state, setState] = useState<State>({ status: "idle" });
54 const sourceRef = useRef(props.source);
55 sourceRef.current = props.source;
56 const restoreRef = useRef(props.restoreFrame ?? 0);
57 restoreRef.current = props.restoreFrame ?? 0;
58 const abort = useRef<AbortController | null>(null);
60 const span = useMemo(() => movieSpan(frameTimes, audio), [frameTimes, audio]);
61 const length = span / speed;
62 const recording = state.status === "recording";
63 // The recorder draws on the display's own refresh, so a run whose frames
64 // come faster than that loses some of them; a slower speed spreads them out.
65 const fps = length > 0 ? frameTimes.length / length : 0;
66 const crowded = fps > 60;
68 const start = async () => {
69 if (recording || support.status !== "available") return;
70 const controller = new AbortController();
71 abort.current = controller;
72 setState({ status: "recording", fraction: 0 });
73 onRecording?.(true);
74 try {
75 const result = await recordMovie({
76 source: sourceRef.current,
77 frameTimes,
78 audio,
79 overlay,
80 aspect,
81 width,
82 speed,
83 restoreFrame: restoreRef.current,
84 signal: controller.signal,
85 onProgress: (fraction) => setState({ status: "recording", fraction }),
86 });
87 saveMovie(result, `${filename}-${speed === 1 ? "realtime" : `${speed}x`}`);
88 setState({ status: "done", result, speed });
89 } catch (e) {
90 setState({ status: "error", message: e instanceof Error ? e.message : String(e) });
91 } finally {
92 abort.current = null;
93 onRecording?.(false);
94 }
95 };
97 return (
98 <div className="pl-movie">
99 <button
100 className="pl-btn"
101 onClick={() => void start()}
102 disabled={recording || support.status !== "available" || frameTimes.length < 2}
103 title="Record the scene as it is oriented, with the sound"
104 >
105 Save movie
106 </button>
107 <label className="pl-muted">
108 speed{" "}
109 <select
110 className="pl-select"
111 style={{ width: "auto" }}
112 value={speed}
113 disabled={recording}
114 onChange={(e) => setSpeed(Number(e.target.value))}
115 >
116 {SPEEDS.map((s) => (
117 <option key={s.value} value={s.value}>
118 {s.label}
119 </option>
120 ))}
121 </select>
122 </label>
123 {recording && (
124 <>
125 <div className="pl-bar" aria-label="recording progress">
126 <div style={{ width: `${Math.round(state.fraction * 100)}%` }} />
127 </div>
128 <button className="pl-btn pl-btn-danger" onClick={() => abort.current?.abort()}>
129 Stop
130 </button>
131 <span className="pl-muted">
132 recording in real time, {Math.max(0, length * (1 - state.fraction)).toFixed(1)} s left;
133 keep this tab in front
134 </span>
135 </>
136 )}
137 {!recording && support.status !== "available" && (
138 <span className="pl-muted">Not available here ({support.reason}).</span>
139 )}
140 {!recording && support.status === "available" && state.status !== "done" && (
141 <span className="pl-muted">
142 {length.toFixed(1)} s of {support.ext.toUpperCase()} with the sound, written in real time
143 {speed === 1 ? "" : "; the sound is slowed with the picture and drops in pitch"}.
144 {crowded
145 ? ` 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.`
146 : ""}
147 </span>
148 )}
149 {!recording && state.status === "done" && (
150 <span className="pl-muted">
151 Saved {state.result.seconds.toFixed(1)} s, {state.result.width} × {state.result.height},{" "}
152 {(state.result.blob.size / 1e6).toFixed(1)} MB, {state.result.drawn} of{" "}
153 {state.result.frames} frames drawn
154 {state.result.drawn < state.result.frames ? " (the rest were dropped to keep time)" : ""}.
155 </span>
156 )}
157 {state.status === "error" && <span className="pl-issue">{state.message}</span>}
158 </div>
159 );
↑↓ move↵openescclose