2004981Plate dynamics lab 2: a hammer on a hung plate by Rayleigh-Ritz and by Mindlin-Reissner finite elementsJeremy Magland 1/**
2 * The scene as a movie, with the radiated sound as its audio track.
3 *
4 * The browser's own recorder does the encoding: the composite canvas below is
5 * captured as a video track, the sound is played into a MediaStream audio
6 * track, and MediaRecorder muxes the two into WebM (or MP4 where the browser
7 * writes it). That makes the recording real time, which is the price of using
8 * nothing but the platform: a three-second movie takes three seconds to write,
9 * and a machine too busy to keep up drops frames rather than slowing down.
10 * The result reports how many of the run's frames were actually drawn.
11 *
12 * The composite canvas is the scene, drawn at the movie's own resolution
13 * rather than the one on screen, with a title bar carrying the simulated time
14 * and a caption bar carrying the field, its colour scale and the exaggeration.
15 * Everything a viewer needs to read the movie is therefore in the file.
16 */
17import { colormapTable, getColormap, toAudioBuffer, type AudioPrep } from "@plate-dyn/core";
18import { withWebmDuration } from "./webmDuration";
20export interface MovieLegend {
21 label: string;
22 unit: string;
23 /** Range of the colour scale, in SI; `scale` multiplies it for the labels */
24 min: number;
25 max: number;
26 scale: number;
27 colormap: string;
28}
30export interface MovieOverlay {
31 title: string;
32 /** A second line: what is exaggerated, and by how much */
33 note?: string;
34 legend?: MovieLegend;
35}
37/** The scene the recorder draws from (Plate3DView's imperative handle). */
38export interface MovieSource {
39 canvas: () => HTMLCanvasElement | null;
40 drawFrame: (k: number, size?: { width: number; height: number }) => void;
41}
43export interface MovieOptions {
44 source: MovieSource;
45 /** Simulated time of every frame [s] */
46 frameTimes: Float64Array;
47 /** The sound, normalized as the player and the WAV export have it */
48 audio?: AudioPrep;
49 /** 1 is real time; below that the movie and the sound are slowed together */
50 speed?: number;
51 /** Width of the movie in pixels; the height follows `aspect` plus the bars */
52 width?: number;
53 /** Height of the scene as a fraction of the width */
54 aspect?: number;
55 overlay?: MovieOverlay;
56 /** Frame to leave on screen when the recording ends */
57 restoreFrame?: number;
58 onProgress?: (fraction: number) => void;
59 signal?: AbortSignal;
60}
62export interface MovieResult {
63 blob: Blob;
64 ext: string;
65 mimeType: string;
66 /** Length of the movie [s] */
67 seconds: number;
68 width: number;
69 height: number;
70 /** Frames the run has, and how many of them the recording drew */
71 frames: number;
72 drawn: number;
73}
75/**
76 * Containers to try, in order. WebM with Opus comes first because it is the
77 * one Chrome and Firefox record with the sound: their MP4 recording accepts no
78 * audio codec at all (`isTypeSupported` says no to every `video/mp4` string
79 * that names one), and a movie without its sound is not what was asked for.
80 * The MP4 entries are for Safari, which records MP4 with AAC and no WebM.
81 */
82const FORMATS = [
83 { mimeType: "video/webm;codecs=vp9,opus", ext: "webm" },
84 { mimeType: "video/webm;codecs=vp8,opus", ext: "webm" },
85 { mimeType: "video/webm", ext: "webm" },
86 { mimeType: 'video/mp4;codecs="avc1.42E01E,mp4a.40.2"', ext: "mp4" },
87 { mimeType: "video/mp4", ext: "mp4" },
88];
90const defaultSupported = (mime: string): boolean =>
91 typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(mime);
93/** The first container this browser can write, or null. */
94export function pickMovieFormat(
95 isSupported: (mime: string) => boolean = defaultSupported,
96): { mimeType: string; ext: string } | null {
97 return FORMATS.find((f) => isSupported(f.mimeType)) ?? null;
98}
100export type MovieSupport =
101 | { status: "available"; mimeType: string; ext: string }
102 | { status: "unavailable"; reason: string };
104/** Whether this browser can record the scene at all, and in what. */
105export function movieSupport(): MovieSupport {
106 if (typeof MediaRecorder === "undefined")
107 return { status: "unavailable", reason: "this browser has no MediaRecorder" };
108 if (
109 typeof HTMLCanvasElement === "undefined" ||
110 typeof HTMLCanvasElement.prototype.captureStream !== "function"
111 )
112 return { status: "unavailable", reason: "this browser cannot capture a canvas as video" };
113 const f = pickMovieFormat();
114 if (!f)
115 return { status: "unavailable", reason: "this browser writes no video format the lab can use" };
116 return { status: "available", mimeType: f.mimeType, ext: f.ext };
117}
119/**
120 * The last frame whose time has arrived at simulated time t, searching forward
121 * from `from`. Frames are monotonic, so a run that falls behind skips to the
122 * frame it should be showing rather than replaying the ones it missed.
123 */
124export function frameAtTime(frameTimes: ArrayLike<number>, t: number, from = 0): number {
125 let k = Math.max(0, Math.min(from, frameTimes.length - 1));
126 while (k + 1 < frameTimes.length && frameTimes[k + 1] <= t) k++;
127 return k;
128}
130/** Simulated span of the movie [s]: the frames, or the sound if it lasts longer. */
131export function movieSpan(frameTimes: ArrayLike<number>, audio?: AudioPrep): number {
132 const last = frameTimes.length ? frameTimes[frameTimes.length - 1] : 0;
133 return Math.max(last, audio?.durationS ?? 0);
134}
136const even = (x: number): number => 2 * Math.round(x / 2);
138const TEXT = "#1c2430";
139const MUTED = "#5b6573";
140const RULE = "#d9dde3";
142function fmt(x: number): string {
143 if (x === 0) return "0";
144 const a = Math.abs(x);
145 if (a >= 1e4 || a < 1e-2) return x.toExponential(2);
146 return Number(x.toPrecision(3)).toString();
147}
149/** Record the scene, resolving with the finished file. */
150export async function recordMovie(opts: MovieOptions): Promise<MovieResult> {
151 const { source, frameTimes, audio, overlay, onProgress, signal } = opts;
152 const speed = opts.speed ?? 1;
153 const support = movieSupport();
154 if (support.status !== "available") throw new Error(support.reason);
155 if (!frameTimes.length) throw new Error("the run kept no frames to record");
156 if (!(speed > 0)) throw new Error("the speed must be positive");
158 const width = even(opts.width ?? 960);
159 const sceneH = even(width * (opts.aspect ?? 0.7));
160 const titleH = overlay ? 44 : 0;
161 const legendH = overlay ? (overlay.legend ? 62 : 32) : 0;
162 const height = even(sceneH + titleH + legendH);
164 const out = document.createElement("canvas");
165 out.width = width;
166 out.height = height;
167 const ctx = out.getContext("2d");
168 if (!ctx) throw new Error("this browser gave no 2-D canvas to compose the movie on");
170 const drawOverlay = (t: number) => {
171 ctx.fillStyle = "#fff";
172 ctx.fillRect(0, 0, width, titleH);
173 ctx.fillRect(0, titleH + sceneH, width, legendH);
174 ctx.strokeStyle = RULE;
175 ctx.lineWidth = 1;
176 ctx.beginPath();
177 ctx.moveTo(0, titleH + 0.5);
178 ctx.lineTo(width, titleH + 0.5);
179 ctx.moveTo(0, titleH + sceneH + 0.5);
180 ctx.lineTo(width, titleH + sceneH + 0.5);
181 ctx.stroke();
182 if (!overlay) return;
183 ctx.textBaseline = "alphabetic";
184 ctx.fillStyle = TEXT;
185 ctx.font = "600 19px system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
186 ctx.textAlign = "left";
187 ctx.fillText(overlay.title, 16, 29);
188 ctx.textAlign = "right";
189 ctx.font = "17px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
190 ctx.fillText(
191 t < 0.1 ? `t = ${(t * 1e3).toFixed(3)} ms` : `t = ${t.toFixed(3)} s`,
192 width - 16,
193 29,
194 );
195 ctx.textAlign = "left";
196 const base = titleH + sceneH;
197 const lg = overlay.legend;
198 if (lg) {
199 ctx.font = "13px system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
200 ctx.fillStyle = TEXT;
201 ctx.fillText(`${lg.label} [${lg.unit}]`, 16, base + 20);
202 const bx = 16;
203 const by = base + 30;
204 const bw = 240;
205 const bh = 10;
206 const table = colormapTable(getColormap(lg.colormap));
207 for (let i = 0; i < bw; i++) {
208 const k = Math.round((i / (bw - 1)) * 255) * 3;
209 ctx.fillStyle = `rgb(${table[k]},${table[k + 1]},${table[k + 2]})`;
210 ctx.fillRect(bx + i, by, 1, bh);
211 }
212 ctx.strokeStyle = RULE;
213 ctx.strokeRect(bx + 0.5, by + 0.5, bw - 1, bh - 1);
214 ctx.fillStyle = MUTED;
215 ctx.font = "12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
216 ctx.fillText(fmt(lg.min * lg.scale), bx, by + bh + 14);
217 ctx.textAlign = "right";
218 ctx.fillText(fmt(lg.max * lg.scale), bx + bw, by + bh + 14);
219 ctx.textAlign = "left";
220 }
221 if (overlay.note) {
222 ctx.fillStyle = MUTED;
223 ctx.font = "13px system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
224 ctx.textAlign = "right";
225 ctx.fillText(overlay.note, width - 16, base + (lg ? 40 : 21));
226 ctx.textAlign = "left";
227 }
228 };
230 let drawn = 0;
231 const render = (k: number) => {
232 source.drawFrame(k, { width, height: sceneH });
233 const src = source.canvas();
234 ctx.fillStyle = "#fff";
235 ctx.fillRect(0, titleH, width, sceneH);
236 if (src) ctx.drawImage(src, 0, titleH, width, sceneH);
237 drawOverlay(frameTimes[k] ?? 0);
238 drawn++;
239 };
241 const stream = out.captureStream(0);
242 const video = stream.getVideoTracks()[0] as CanvasCaptureMediaStreamTrack;
243 let actx: AudioContext | undefined;
244 let node: AudioBufferSourceNode | undefined;
245 const chunks: Blob[] = [];
246 let rec: MediaRecorder | undefined;
247 try {
248 if (audio && audio.samples.length > 1) {
249 actx = new AudioContext();
250 await actx.resume();
251 const dest = actx.createMediaStreamDestination();
252 node = actx.createBufferSource();
253 node.buffer = toAudioBuffer(actx, audio);
254 // below real time the sound is slowed like a tape, and drops in pitch with it
255 node.playbackRate.value = speed;
256 node.connect(dest);
257 const track = dest.stream.getAudioTracks()[0];
258 if (track) stream.addTrack(track);
259 }
261 rec = new MediaRecorder(stream, { mimeType: support.mimeType, videoBitsPerSecond: 8e6 });
262 rec.ondataavailable = (e) => {
263 if (e.data.size > 0) chunks.push(e.data);
264 };
265 const stopped = new Promise<void>((resolve) => {
266 rec!.onstop = () => resolve();
267 });
269 const span = movieSpan(frameTimes, audio);
270 const wall = span / speed;
271 render(0);
272 video.requestFrame();
273 const tRec = performance.now();
274 rec.start(250);
275 node?.start();
277 const t0 = performance.now();
278 let last = 0;
279 await new Promise<void>((resolve) => {
280 const step = () => {
281 const elapsed = (performance.now() - t0) / 1000;
282 const k = frameAtTime(frameTimes, elapsed * speed, last);
283 if (k !== last) {
284 last = k;
285 render(k);
286 }
287 video.requestFrame();
288 onProgress?.(Math.min(1, elapsed / Math.max(wall, 1e-6)));
289 if (signal?.aborted || elapsed >= wall + 0.15) resolve();
290 else requestAnimationFrame(step);
291 };
292 requestAnimationFrame(step);
293 });
295 rec.stop();
296 await stopped;
297 const seconds = (performance.now() - tRec) / 1000;
298 let blob = new Blob(chunks, { type: support.mimeType });
299 if (blob.size === 0) throw new Error("the browser's recorder produced an empty file");
300 if (support.ext === "webm") {
301 // the recorder leaves the length out, since it does not know it until now
302 const patched = withWebmDuration(new Uint8Array(await blob.arrayBuffer()), seconds);
303 blob = new Blob([patched as BlobPart], { type: support.mimeType });
304 }
305 return {
306 blob,
307 ext: support.ext,
308 mimeType: support.mimeType,
309 seconds,
310 width,
311 height,
312 frames: frameTimes.length,
313 drawn,
314 };
315 } finally {
316 try {
317 node?.stop();
318 } catch {
319 /* already stopped */
320 }
321 void actx?.close();
322 for (const t of stream.getTracks()) t.stop();
323 // back to the size and the frame the app is showing
324 source.drawFrame(opts.restoreFrame ?? 0);
325 }
326}
328/** Hand the finished movie to the browser as a download. */
329export function saveMovie(result: MovieResult, filename: string): void {
330 const url = URL.createObjectURL(result.blob);
331 const a = document.createElement("a");
332 a.href = url;
333 a.download = `${filename}.${result.ext}`;
334 a.click();
335 setTimeout(() => URL.revokeObjectURL(url), 0);
336}