/** * The scene as a movie, with the radiated sound as its audio track. * * The browser's own recorder does the encoding: the composite canvas below is * captured as a video track, the sound is played into a MediaStream audio * track, and MediaRecorder muxes the two into WebM (or MP4 where the browser * writes it). That makes the recording real time, which is the price of using * nothing but the platform: a three-second movie takes three seconds to write, * and a machine too busy to keep up drops frames rather than slowing down. * The result reports how many of the run's frames were actually drawn. * * The composite canvas is the scene, drawn at the movie's own resolution * rather than the one on screen, with a title bar carrying the simulated time * and a caption bar carrying the field, its colour scale and the exaggeration. * Everything a viewer needs to read the movie is therefore in the file. */ import { colormapTable, getColormap, toAudioBuffer, type AudioPrep } from "@plate-dyn/core"; import { withWebmDuration } from "./webmDuration"; export interface MovieLegend { label: string; unit: string; /** Range of the colour scale, in SI; `scale` multiplies it for the labels */ min: number; max: number; scale: number; colormap: string; } export interface MovieOverlay { title: string; /** A second line: what is exaggerated, and by how much */ note?: string; legend?: MovieLegend; } /** The scene the recorder draws from (Plate3DView's imperative handle). */ export interface MovieSource { canvas: () => HTMLCanvasElement | null; drawFrame: (k: number, size?: { width: number; height: number }) => void; } export interface MovieOptions { source: MovieSource; /** Simulated time of every frame [s] */ frameTimes: Float64Array; /** The sound, normalized as the player and the WAV export have it */ audio?: AudioPrep; /** 1 is real time; below that the movie and the sound are slowed together */ speed?: number; /** Width of the movie in pixels; the height follows `aspect` plus the bars */ width?: number; /** Height of the scene as a fraction of the width */ aspect?: number; overlay?: MovieOverlay; /** Frame to leave on screen when the recording ends */ restoreFrame?: number; onProgress?: (fraction: number) => void; signal?: AbortSignal; } export interface MovieResult { blob: Blob; ext: string; mimeType: string; /** Length of the movie [s] */ seconds: number; width: number; height: number; /** Frames the run has, and how many of them the recording drew */ frames: number; drawn: number; } /** * Containers to try, in order. WebM with Opus comes first because it is the * one Chrome and Firefox record with the sound: their MP4 recording accepts no * audio codec at all (`isTypeSupported` says no to every `video/mp4` string * that names one), and a movie without its sound is not what was asked for. * The MP4 entries are for Safari, which records MP4 with AAC and no WebM. */ const FORMATS = [ { mimeType: "video/webm;codecs=vp9,opus", ext: "webm" }, { mimeType: "video/webm;codecs=vp8,opus", ext: "webm" }, { mimeType: "video/webm", ext: "webm" }, { mimeType: 'video/mp4;codecs="avc1.42E01E,mp4a.40.2"', ext: "mp4" }, { mimeType: "video/mp4", ext: "mp4" }, ]; const defaultSupported = (mime: string): boolean => typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(mime); /** The first container this browser can write, or null. */ export function pickMovieFormat( isSupported: (mime: string) => boolean = defaultSupported, ): { mimeType: string; ext: string } | null { return FORMATS.find((f) => isSupported(f.mimeType)) ?? null; } export type MovieSupport = | { status: "available"; mimeType: string; ext: string } | { status: "unavailable"; reason: string }; /** Whether this browser can record the scene at all, and in what. */ export function movieSupport(): MovieSupport { if (typeof MediaRecorder === "undefined") return { status: "unavailable", reason: "this browser has no MediaRecorder" }; if ( typeof HTMLCanvasElement === "undefined" || typeof HTMLCanvasElement.prototype.captureStream !== "function" ) return { status: "unavailable", reason: "this browser cannot capture a canvas as video" }; const f = pickMovieFormat(); if (!f) return { status: "unavailable", reason: "this browser writes no video format the lab can use" }; return { status: "available", mimeType: f.mimeType, ext: f.ext }; } /** * The last frame whose time has arrived at simulated time t, searching forward * from `from`. Frames are monotonic, so a run that falls behind skips to the * frame it should be showing rather than replaying the ones it missed. */ export function frameAtTime(frameTimes: ArrayLike, t: number, from = 0): number { let k = Math.max(0, Math.min(from, frameTimes.length - 1)); while (k + 1 < frameTimes.length && frameTimes[k + 1] <= t) k++; return k; } /** Simulated span of the movie [s]: the frames, or the sound if it lasts longer. */ export function movieSpan(frameTimes: ArrayLike, audio?: AudioPrep): number { const last = frameTimes.length ? frameTimes[frameTimes.length - 1] : 0; return Math.max(last, audio?.durationS ?? 0); } const even = (x: number): number => 2 * Math.round(x / 2); const TEXT = "#1c2430"; const MUTED = "#5b6573"; const RULE = "#d9dde3"; function fmt(x: number): string { if (x === 0) return "0"; const a = Math.abs(x); if (a >= 1e4 || a < 1e-2) return x.toExponential(2); return Number(x.toPrecision(3)).toString(); } /** Record the scene, resolving with the finished file. */ export async function recordMovie(opts: MovieOptions): Promise { const { source, frameTimes, audio, overlay, onProgress, signal } = opts; const speed = opts.speed ?? 1; const support = movieSupport(); if (support.status !== "available") throw new Error(support.reason); if (!frameTimes.length) throw new Error("the run kept no frames to record"); if (!(speed > 0)) throw new Error("the speed must be positive"); const width = even(opts.width ?? 960); const sceneH = even(width * (opts.aspect ?? 0.7)); const titleH = overlay ? 44 : 0; const legendH = overlay ? (overlay.legend ? 62 : 32) : 0; const height = even(sceneH + titleH + legendH); const out = document.createElement("canvas"); out.width = width; out.height = height; const ctx = out.getContext("2d"); if (!ctx) throw new Error("this browser gave no 2-D canvas to compose the movie on"); const drawOverlay = (t: number) => { ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, width, titleH); ctx.fillRect(0, titleH + sceneH, width, legendH); ctx.strokeStyle = RULE; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, titleH + 0.5); ctx.lineTo(width, titleH + 0.5); ctx.moveTo(0, titleH + sceneH + 0.5); ctx.lineTo(width, titleH + sceneH + 0.5); ctx.stroke(); if (!overlay) return; ctx.textBaseline = "alphabetic"; ctx.fillStyle = TEXT; ctx.font = "600 19px system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"; ctx.textAlign = "left"; ctx.fillText(overlay.title, 16, 29); ctx.textAlign = "right"; ctx.font = "17px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; ctx.fillText(`t = ${t.toFixed(3)} s`, width - 16, 29); ctx.textAlign = "left"; const base = titleH + sceneH; const lg = overlay.legend; if (lg) { ctx.font = "13px system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"; ctx.fillStyle = TEXT; ctx.fillText(`${lg.label} [${lg.unit}]`, 16, base + 20); const bx = 16; const by = base + 30; const bw = 240; const bh = 10; const table = colormapTable(getColormap(lg.colormap)); for (let i = 0; i < bw; i++) { const k = Math.round((i / (bw - 1)) * 255) * 3; ctx.fillStyle = `rgb(${table[k]},${table[k + 1]},${table[k + 2]})`; ctx.fillRect(bx + i, by, 1, bh); } ctx.strokeStyle = RULE; ctx.strokeRect(bx + 0.5, by + 0.5, bw - 1, bh - 1); ctx.fillStyle = MUTED; ctx.font = "12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; ctx.fillText(fmt(lg.min * lg.scale), bx, by + bh + 14); ctx.textAlign = "right"; ctx.fillText(fmt(lg.max * lg.scale), bx + bw, by + bh + 14); ctx.textAlign = "left"; } if (overlay.note) { ctx.fillStyle = MUTED; ctx.font = "13px system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"; ctx.textAlign = "right"; ctx.fillText(overlay.note, width - 16, base + (lg ? 40 : 21)); ctx.textAlign = "left"; } }; let drawn = 0; const render = (k: number) => { source.drawFrame(k, { width, height: sceneH }); const src = source.canvas(); ctx.fillStyle = "#fff"; ctx.fillRect(0, titleH, width, sceneH); if (src) ctx.drawImage(src, 0, titleH, width, sceneH); drawOverlay(frameTimes[k] ?? 0); drawn++; }; const stream = out.captureStream(0); const video = stream.getVideoTracks()[0] as CanvasCaptureMediaStreamTrack; let actx: AudioContext | undefined; let node: AudioBufferSourceNode | undefined; const chunks: Blob[] = []; let rec: MediaRecorder | undefined; try { if (audio && audio.samples.length > 1) { actx = new AudioContext(); await actx.resume(); const dest = actx.createMediaStreamDestination(); node = actx.createBufferSource(); node.buffer = toAudioBuffer(actx, audio); // below real time the sound is slowed like a tape, and drops in pitch with it node.playbackRate.value = speed; node.connect(dest); const track = dest.stream.getAudioTracks()[0]; if (track) stream.addTrack(track); } rec = new MediaRecorder(stream, { mimeType: support.mimeType, videoBitsPerSecond: 8e6 }); rec.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); }; const stopped = new Promise((resolve) => { rec!.onstop = () => resolve(); }); const span = movieSpan(frameTimes, audio); const wall = span / speed; render(0); video.requestFrame(); const tRec = performance.now(); rec.start(250); node?.start(); const t0 = performance.now(); let last = 0; await new Promise((resolve) => { const step = () => { const elapsed = (performance.now() - t0) / 1000; const k = frameAtTime(frameTimes, elapsed * speed, last); if (k !== last) { last = k; render(k); } video.requestFrame(); onProgress?.(Math.min(1, elapsed / Math.max(wall, 1e-6))); if (signal?.aborted || elapsed >= wall + 0.15) resolve(); else requestAnimationFrame(step); }; requestAnimationFrame(step); }); rec.stop(); await stopped; const seconds = (performance.now() - tRec) / 1000; let blob = new Blob(chunks, { type: support.mimeType }); if (blob.size === 0) throw new Error("the browser's recorder produced an empty file"); if (support.ext === "webm") { // the recorder leaves the length out, since it does not know it until now const patched = withWebmDuration(new Uint8Array(await blob.arrayBuffer()), seconds); blob = new Blob([patched as BlobPart], { type: support.mimeType }); } return { blob, ext: support.ext, mimeType: support.mimeType, seconds, width, height, frames: frameTimes.length, drawn, }; } finally { try { node?.stop(); } catch { /* already stopped */ } void actx?.close(); for (const t of stream.getTracks()) t.stop(); // back to the size and the frame the app is showing source.drawFrame(opts.restoreFrame ?? 0); } } /** Hand the finished movie to the browser as a download. */ export function saveMovie(result: MovieResult, filename: string): void { const url = URL.createObjectURL(result.blob); const a = document.createElement("a"); a.href = url; a.download = `${filename}.${result.ext}`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 0); }