Movie export: recompute the run from t = 0 into a captioned MP4
An 'Export movie' control replays the run — same seed, same source, the
current parameters and colormap — capturing frames composited from the
live panels (current camera and theme, colorbar per species, caption
with model, parameters and running t) and encoding H.264 in the browser
via WebCodecs, muxed by mp4-muxer. Determinism makes the replay exact:
the one session is reused and ends where the run was, so the app
resumes as if nothing happened; cancelling steps the remainder to the
same place.
Playback speed is simulation-time units per second of video (0.1x-20x,
default 10x). Frame timestamps come from simulation time, so the speed
is exact even when the frame budget (each step at most one frame,
3600 frames max) lowers the effective frame rate. Optional auto-rotate
orbits the camera once per 2 minutes of video, restoring the view
afterwards.
8 changed files+606−36
README.mdmodified+5−4View file
@@ -32,10 +32,11 @@ U_k = (U_k + dt*R_k) / (1 + dt*D_k*l(l+1))
3232
3333 You watch the patterns emerge in real time on orbitable 3D spheres (one per
3434 species, cameras synced), with pause/resume, re-seeding, live parameter editing,
35-and colormap selection. The display can oversample the solver — the state is
36-spectral, so evaluating it on a finer grid for rendering is exact
37-interpolation, not smoothing. This never touches the solver or its grid; by
38-default it turns on only when the solver grid is coarse.
35+colormap selection, and movie download — the run is recomputed from t = 0 and
36+encoded to a captioned MP4 in the browser (WebCodecs). The display can
37+oversample the solver — the state is spectral, so evaluating it on a finer grid
38+for rendering is exact interpolation, not smoothing. This never touches the
39+solver or its grid; by default it turns on only when the solver grid is coarse.
3940
4041 Three models are included, one `.m` file each:
4142
index.htmlmodified+20−0View file
@@ -49,6 +49,8 @@
4949 display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center;
5050 padding: 6px 0;
5151 }
52+ /* display:flex above would otherwise override the UA's [hidden] rule */
53+ .controls[hidden] { display: none; }
5254 .controls label { color: var(--ink-2); font-size: 13px; white-space: nowrap; }
5355 select, input[type="number"], button {
5456 font: inherit; font-size: 13px;
@@ -195,6 +197,24 @@
195197 <button id="benchmark">Benchmark</button>
196198 <button id="reseed">Re-seed</button>
197199 <button id="resetview">Reset view</button>
200+ <button id="movietoggle" title="Export the run as an MP4 movie">Export movie</button>
201+ </div>
202+ <div class="controls" id="moviebar" hidden>
203+ <label title="Playback speed: simulation-time units per second of video — 1× plays one time unit per second">movie speed
204+ <select id="moviespeed">
205+ <option value="0.1">0.1×</option>
206+ <option value="0.5">0.5×</option>
207+ <option value="1">1×</option>
208+ <option value="3">3×</option>
209+ <option value="5">5×</option>
210+ <option value="10" selected>10×</option>
211+ <option value="20">20×</option>
212+ </select>
213+ </label>
214+ <label title="Slowly orbit the camera during the movie — one revolution per 2 minutes of video, starting from the current view (which is restored afterwards)">
215+ <input type="checkbox" id="movierotate" checked /> auto-rotate
216+ </label>
217+ <button id="movie" title="Recompute the run from t = 0 and download it as an MP4 movie">Export</button>
198218 </div>
199219 <div class="controls" id="params"></div>
200220 <div id="panels"></div>
package-lock.jsonmodified+24−0View file
@@ -9,6 +9,7 @@
99 "version": "0.1.0",
1010 "license": "CECILL-2.1",
1111 "dependencies": {
12+ "mp4-muxer": "^5.2.2",
1213 "numbl": "file:../../numbl",
1314 "three": "^0.183.0"
1415 },
@@ -1324,6 +1325,12 @@
13241325 "tslib": "^2.4.0"
13251326 }
13261327 },
1328+ "node_modules/@types/dom-webcodecs": {
1329+ "version": "0.1.18",
1330+ "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
1331+ "integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==",
1332+ "license": "MIT"
1333+ },
13271334 "node_modules/@types/estree": {
13281335 "version": "1.0.9",
13291336 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1370,6 +1377,12 @@
13701377 "dev": true,
13711378 "license": "MIT"
13721379 },
1380+ "node_modules/@types/wicg-file-system-access": {
1381+ "version": "2020.9.8",
1382+ "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.8.tgz",
1383+ "integrity": "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==",
1384+ "license": "MIT"
1385+ },
13731386 "node_modules/@types/yauzl": {
13741387 "version": "2.10.3",
13751388 "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@@ -2346,6 +2359,17 @@
23462359 "dev": true,
23472360 "license": "MIT"
23482361 },
2362+ "node_modules/mp4-muxer": {
2363+ "version": "5.2.2",
2364+ "resolved": "https://registry.npmjs.org/mp4-muxer/-/mp4-muxer-5.2.2.tgz",
2365+ "integrity": "sha512-dhozjTywI0h2qFzeShagt8YYw811fh1XlwiDCE2f6Aeqf6xG2CyuShoSa5E0AZDO8pPF0JOZ3wOmWBNWIGdSpQ==",
2366+ "deprecated": "This library is superseded by Mediabunny. Please migrate to it.",
2367+ "license": "MIT",
2368+ "dependencies": {
2369+ "@types/dom-webcodecs": "^0.1.6",
2370+ "@types/wicg-file-system-access": "^2020.9.5"
2371+ }
2372+ },
23492373 "node_modules/ms": {
23502374 "version": "2.1.3",
23512375 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
package.jsonmodified+1−0View file
@@ -18,6 +18,7 @@
1818 "bench:native": "node scripts/compare-native.mjs"
1919 },
2020 "dependencies": {
21+ "mp4-muxer": "^5.2.2",
2122 "numbl": "file:../../numbl",
2223 "three": "^0.183.0"
2324 },
src/main.tsmodified+231−28View file
@@ -19,8 +19,9 @@ import {
1919 type SphereMeshTopology,
2020 } from './render/sphereMesh.ts';
2121 import { SphereScene } from './render/SphereScene.ts';
22-import { Colorbar } from './render/colorbar.ts';
22+import { Colorbar, fmtValue } from './render/colorbar.ts';
2323 import { colormaps, colormapNames } from './render/colormaps.ts';
24+import { MovieRecorder } from './render/movie.ts';
2425
2526 const $ = <T extends HTMLElement>(id: string): T =>
2627 document.getElementById(id) as T;
@@ -33,6 +34,11 @@ const elRunPause = $<HTMLButtonElement>('runpause');
3334 const elBenchmark = $<HTMLButtonElement>('benchmark');
3435 const elReseed = $<HTMLButtonElement>('reseed');
3536 const elResetView = $<HTMLButtonElement>('resetview');
37+const elMovieToggle = $<HTMLButtonElement>('movietoggle');
38+const elMovieBar = $('moviebar');
39+const elMovieSpeed = $<HTMLSelectElement>('moviespeed');
40+const elMovieRotate = $<HTMLInputElement>('movierotate');
41+const elMovie = $<HTMLButtonElement>('movie');
3642 const elParams = $('params');
3743 const elPanels = $('panels');
3844 const elStats = $('stats');
@@ -110,6 +116,22 @@ function resolveOversample(): number {
110116 return os;
111117 }
112118
119+/**
120+ * Movie frame rate, and a cap on frames per movie. Playback speed comes from
121+ * the UI, in simulation-time units per second of video; the movie's length is
122+ * the run's t at that speed, and the frame count follows from it — capped by
123+ * the run's own step count (a step is at most one frame) and by
124+ * MOVIE_MAX_FRAMES to bound encode time and file size. Frame timestamps are
125+ * derived from simulation time, so a capped movie keeps its duration and
126+ * speed exactly, at a lower effective frame rate.
127+ */
128+const MOVIE_FPS = 30;
129+const MOVIE_MAX_FRAMES = 3600;
130+
131+/** Movie auto-rotation: camera revolutions per second of video. Measured in
132+ * video time, so the orbit pace on screen is the same at every export speed. */
133+const MOVIE_ROTATE_RPS = 1 / 120;
134+
113135 // ---------------------------------------------------------------- state
114136 let device: GPUDevice | null = null;
115137 let session: ModelSession | null = null;
@@ -130,6 +152,8 @@ let seed = 1;
130152 let running = false;
131153 let adapterName = '';
132154 let pumping = false;
155+let movieBusy = false;
156+let movieCancel = false;
133157 let solverMs = 0;
134158 let frameMs = 0;
135159 let lastMeasure = 0;
@@ -226,6 +250,13 @@ elReseed.addEventListener('click', () => {
226250 elResetView.addEventListener('click', () => {
227251 for (const s of scenes) s.resetCamera();
228252 });
253+elMovieToggle.addEventListener('click', () => {
254+ elMovieBar.hidden = !elMovieBar.hidden;
255+});
256+elMovie.addEventListener('click', () => {
257+ if (movieBusy) movieCancel = true;
258+ else void recordMovie();
259+});
229260
230261 elRecompile.addEventListener('click', () => {
231262 editedSource = editor.value;
@@ -545,40 +576,212 @@ async function pump(): Promise<void> {
545576 * These are ordinary steps: the simulation advances by them.
546577 */
547578 async function benchmark(): Promise<void> {
548- if (!session) return;
579+ if (!session || movieBusy) return;
549580 setRunning(false);
550581 const BATCH = 32;
551582 const DURATION_MS = 2000;
552583 elBenchResult.textContent = 'benchmarking…';
553- await nextFrame();
584+ // A movie started mid-benchmark would replay while this loop still steps.
585+ elMovie.disabled = true;
586+ try {
587+ await nextFrame();
588+
589+ const gen = generation;
590+ const perStep: number[] = [];
591+ const t0 = performance.now();
592+ while (performance.now() - t0 < DURATION_MS) {
593+ const b0 = performance.now();
594+ session.step(BATCH);
595+ await session.sync();
596+ if (gen !== generation) return;
597+ perStep.push((performance.now() - b0) / BATCH);
598+ }
554599
555- const gen = generation;
556- const perStep: number[] = [];
557- const t0 = performance.now();
558- while (performance.now() - t0 < DURATION_MS) {
559- const b0 = performance.now();
560- session.step(BATCH);
561- await session.sync();
562- if (gen !== generation) return;
563- perStep.push((performance.now() - b0) / BATCH);
600+ const mean = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length;
601+ const all = mean(perStep);
602+ const best = Math.min(...perStep);
603+ const third = Math.max(1, Math.floor(perStep.length / 3));
604+ const first = mean(perStep.slice(0, third));
605+ const last = mean(perStep.slice(-third));
606+ const steps = perStep.length * BATCH;
607+
608+ elBenchResult.innerHTML =
609+ `sustained solver: <b>${all.toFixed(2)} ms/step</b> ` +
610+ `(${(1000 / all).toFixed(0)} steps/s) · best ${best.toFixed(2)} · ` +
611+ `ramp ${(first / last).toFixed(2)}× (${first.toFixed(2)} → ${last.toFixed(2)}) · ` +
612+ `${steps} steps in batches of ${BATCH} · ` +
613+ `compare with <code>npm run bench -- --lmax ${session.cfg.lmax}</code>`;
614+ await draw();
615+ updateStats();
616+ } finally {
617+ elMovie.disabled = false;
564618 }
619+}
565620
566- const mean = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length;
567- const all = mean(perStep);
568- const best = Math.min(...perStep);
569- const third = Math.max(1, Math.floor(perStep.length / 3));
570- const first = mean(perStep.slice(0, third));
571- const last = mean(perStep.slice(-third));
572- const steps = perStep.length * BATCH;
573-
574- elBenchResult.innerHTML =
575- `sustained solver: <b>${all.toFixed(2)} ms/step</b> ` +
576- `(${(1000 / all).toFixed(0)} steps/s) · best ${best.toFixed(2)} · ` +
577- `ramp ${(first / last).toFixed(2)}× (${first.toFixed(2)} → ${last.toFixed(2)}) · ` +
578- `${steps} steps in batches of ${BATCH} · ` +
579- `compare with <code>npm run bench -- --lmax ${session.cfg.lmax}</code>`;
580- await draw();
581- updateStats();
621+// ---------------------------------------------------------------- movie
622+function saveBlob(blob: Blob, filename: string): void {
623+ const url = URL.createObjectURL(blob);
624+ const a = document.createElement('a');
625+ a.href = url;
626+ a.download = filename;
627+ a.click();
628+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
629+}
630+
631+/** Submit `n` steps in bounded command buffers — a single buffer encoding
632+ * many thousands of steps can exhaust the encoder. */
633+function submitSteps(n: number): void {
634+ while (n > 0 && session) {
635+ const chunk = Math.min(512, n);
636+ session.step(chunk);
637+ n -= chunk;
638+ }
639+}
640+
641+/** While recording, lock everything that could change the run mid-replay;
642+ * the Movie button itself becomes the cancel button. */
643+function setMovieUi(on: boolean): void {
644+ const locked = [
645+ elModel, elLmax, elOversample, elColormap, elRunPause, elBenchmark,
646+ elReseed, elRecompile, elRevert, elMovieSpeed, elMovieRotate, elMovieToggle,
647+ ];
648+ for (const el of locked) el.disabled = on;
649+ elParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
650+ elMovie.textContent = on ? 'Cancel · 0%' : 'Export';
651+}
652+
653+/**
654+ * Recompute the run from t = 0 and download it as an MP4.
655+ *
656+ * The movie is not a recording of what already happened — it is the same
657+ * trajectory recomputed: same seed, same source, and the *current* parameters
658+ * and colormap throughout. Determinism makes this exact: after the replay the
659+ * state is where it was, so the one session is reused and the app resumes as
660+ * if nothing happened. Frames are composited from the live panels, so the
661+ * movie shows the spheres at the current camera orientation — and the replay
662+ * doubles as the progress display, since it is visible on screen.
663+ */
664+async function recordMovie(): Promise<void> {
665+ if (!session || movieBusy) return;
666+ if (session.steps === 0) {
667+ elMovie.textContent = 'run first';
668+ setTimeout(() => (elMovie.textContent = 'Export'), 1200);
669+ return;
670+ }
671+ movieBusy = true;
672+ movieCancel = false;
673+ const gen = generation;
674+ setMovieUi(true);
675+ let wasRunning = false;
676+ let total = 0;
677+ let done = 0;
678+ let seeded = false;
679+ let camBefore: ReturnType<SphereScene['cameraState']> | undefined;
680+ try {
681+ // An in-flight display-grid swap replaces the scenes whose canvases the
682+ // recorder captures, and resumes the run when it lands — let it finish.
683+ await viewChange;
684+ if (gen !== generation || !session) return;
685+ wasRunning = running;
686+ setRunning(false);
687+ while (pumping) await nextFrame(); // let an in-flight live frame drain
688+ if (gen !== generation || !session) return;
689+ total = session.steps;
690+ const speed = Number(elMovieSpeed.value) || 10;
691+ const rotate = elMovieRotate.checked;
692+ if (rotate) camBefore = scenes[0]?.cameraState();
693+ const durationS = Math.max(session.t / speed, 2 / MOVIE_FPS);
694+ const frames = Math.max(
695+ 2,
696+ Math.min(Math.round(durationS * MOVIE_FPS) + 1, total + 1, MOVIE_MAX_FRAMES),
697+ );
698+ /** The step index captured as frame `i`; both endpoints land exactly. */
699+ const stepAt = (i: number): number => Math.round((i * total) / (frames - 1));
700+
701+ const title =
702+ (presets.find((p) => p.key === elModel.value)?.label ?? model.label) +
703+ (editedSource !== null ? ' (edited)' : '');
704+ const subtitle = model.params
705+ .map((spec) => `${spec.label} ${fmtValue(params[spec.key])}`)
706+ .join(' · ');
707+ const rec = await MovieRecorder.create({
708+ panels: model.species.map((label, k) => ({ canvas: scenes[k].canvas, label })),
709+ title,
710+ subtitle,
711+ speed,
712+ fps: (frames - 1) / durationS,
713+ });
714+
715+ let finished = false;
716+ try {
717+ // Reset the color-range smoothing as a re-seed does, so the shading
718+ // evolves in the movie the way it did live.
719+ session.seed(seed);
720+ seeded = true;
721+ for (const r of ranges) {
722+ r.lo = NaN;
723+ r.hi = NaN;
724+ }
725+ const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
726+ let lastVideoS = 0;
727+ for (let frame = 0; ; ) {
728+ await draw();
729+ if (gen !== generation) return;
730+ if (movieCancel) break;
731+ if (rotate) {
732+ // Advance the orbit by this frame's share of video time; siblings
733+ // follow scenes[0] through the usual camera sync.
734+ const videoS = session.t / speed;
735+ scenes[0]?.orbitBy(2 * Math.PI * MOVIE_ROTATE_RPS * (videoS - lastVideoS));
736+ lastVideoS = videoS;
737+ }
738+ for (const s of scenes) s.renderNow();
739+ await rec.addFrame(
740+ session.t,
741+ model.species.map((_, k) => ({ cmap, lo: ranges[k].lo, hi: ranges[k].hi })),
742+ );
743+ if (++frame >= frames) {
744+ finished = true;
745+ break;
746+ }
747+ const target = stepAt(frame);
748+ submitSteps(target - done);
749+ done = target;
750+ elMovie.textContent = `Cancel · ${Math.round((100 * done) / total)}%`;
751+ }
752+ if (finished) {
753+ const blob = await rec.finish();
754+ saveBlob(
755+ blob,
756+ `turing-sphere-${model.key}-t${session.t.toFixed(2)}-${speed}x.mp4`,
757+ );
758+ }
759+ } finally {
760+ if (!finished) rec.cancel();
761+ }
762+ } catch (e) {
763+ elErr.textContent = `movie: ${e instanceof Error ? e.message : e}`;
764+ } finally {
765+ // A cancelled replay stopped short of where the run was; step the
766+ // remainder — determinism makes this land exactly there.
767+ if (seeded && gen === generation && session) {
768+ while (done < total && gen === generation && session) {
769+ const n = Math.min(4096, total - done);
770+ submitSteps(n);
771+ done += n;
772+ elMovie.textContent = `restoring · ${Math.round((100 * done) / total)}%`;
773+ await session.sync();
774+ }
775+ await draw();
776+ updateStats();
777+ }
778+ if (camBefore && gen === generation) {
779+ for (const s of scenes) s.setCameraState(camBefore);
780+ }
781+ movieBusy = false;
782+ setMovieUi(false);
783+ if (gen === generation) setRunning(wasRunning);
784+ }
582785 }
583786
584787 // ---------------------------------------------------------------- boot
src/render/SphereScene.tsmodified+25−0View file
@@ -107,6 +107,21 @@ export class SphereScene {
107107 this.#needsRender = true;
108108 }
109109
110+ /** The renderer's canvas, for capturing frames. */
111+ get canvas(): HTMLCanvasElement {
112+ return this.#renderer.domElement;
113+ }
114+
115+ /**
116+ * Render immediately, outside the animation loop. A WebGL canvas without
117+ * preserveDrawingBuffer keeps its drawing buffer only until the browser next
118+ * composites, so a capturer must render and copy within one task.
119+ */
120+ renderNow(): void {
121+ this.#needsRender = false;
122+ this.#renderer.render(this.#scene, this.#camera);
123+ }
124+
110125 /** Mirror this scene's camera whenever the other scene's controls move. */
111126 syncCamerasWith(other: SphereScene): void {
112127 const follow = (src: SphereScene, dst: SphereScene) => {
@@ -126,6 +141,16 @@ export class SphereScene {
126141 follow(other, this);
127142 }
128143
144+ /** Orbit the camera about the up axis by `angle` radians, keeping the
145+ * target. Synced sibling scenes follow via their controls, as with a drag. */
146+ orbitBy(angle: number): void {
147+ const offset = this.#camera.position.clone().sub(this.#controls.target);
148+ offset.applyAxisAngle(this.#camera.up, angle);
149+ this.#camera.position.copy(this.#controls.target).add(offset);
150+ this.#controls.update();
151+ this.#needsRender = true;
152+ }
153+
129154 /** Camera pose, for carrying the view across a scene rebuild. */
130155 cameraState(): { position: THREE.Vector3; target: THREE.Vector3; zoom: number } {
131156 return {
src/render/colorbar.tsmodified+6−4View file
@@ -1,5 +1,9 @@
11 import type { ColormapFunc } from './colormaps.ts';
22
3+/** Compact numeric label: 3 significant digits, trailing zeros trimmed. */
4+export const fmtValue = (v: number): string =>
5+ Number.isFinite(v) ? v.toPrecision(3).replace(/\.?0+$/, '') : '—';
6+
37 /** Vertical colorbar drawn on a small canvas, with min/max labels. */
48 export class Colorbar {
59 #canvas: HTMLCanvasElement;
@@ -28,9 +32,7 @@ export class Colorbar {
2832 ctx.fillStyle = `rgb(${r},${g},${b})`;
2933 ctx.fillRect(0, y, this.#canvas.width, 1);
3034 }
31- const fmt = (v: number) =>
32- Number.isFinite(v) ? v.toPrecision(3).replace(/\.?0+$/, '') : '—';
33- this.#maxLabel.textContent = fmt(vmax);
34- this.#minLabel.textContent = fmt(vmin);
35+ this.#maxLabel.textContent = fmtValue(vmax);
36+ this.#minLabel.textContent = fmtValue(vmin);
3537 }
3638 }
src/render/movie.tsadded+294−0View file
@@ -0,0 +1,294 @@
1+/**
2+ * MP4 recording of the live view.
3+ *
4+ * Each frame is composited from the on-screen sphere canvases, so the movie
5+ * shows what the page shows — current camera orientation, colormap, theme —
6+ * with a colorbar per species and a caption (model, parameter values, running
7+ * time). Encoding is WebCodecs H.264 muxed by mp4-muxer, entirely in the
8+ * browser, so capture runs as fast as the solver recomputes rather than at
9+ * playback speed.
10+ */
11+import { ArrayBufferTarget, Muxer } from 'mp4-muxer';
12+import type { ColormapFunc } from './colormaps.ts';
13+import { fmtValue } from './colorbar.ts';
14+
15+export interface MoviePanel {
16+ /**
17+ * The scene's WebGL canvas. It must be rendered in the same task that calls
18+ * addFrame(): without preserveDrawingBuffer the drawing buffer survives only
19+ * until the browser next composites.
20+ */
21+ canvas: HTMLCanvasElement;
22+ label: string;
23+}
24+
25+/** Per-panel colorbar state for one frame. */
26+export interface MovieBar {
27+ cmap: ColormapFunc;
28+ lo: number;
29+ hi: number;
30+}
31+
32+export interface MovieOptions {
33+ panels: MoviePanel[];
34+ /** Caption line 1, bold: the model/preset. */
35+ title: string;
36+ /** Caption line 2: the parameter values. */
37+ subtitle: string;
38+ /** Playback speed: simulation-time units per second of video. Each frame is
39+ * timestamped with its simulation time divided by this, so playback speed
40+ * is exact regardless of how many frames the caller captures. */
41+ speed: number;
42+ /** Effective frames per second, for encoder rate control only. */
43+ fps: number;
44+}
45+
46+/**
47+ * H.264 at level 4.0 (enough for 1080p30, which the layout stays under):
48+ * High profile, then Main, then Constrained Baseline — Chrome's software
49+ * fallback encoder supports only the last.
50+ */
51+const H264_CANDIDATES = ['avc1.640028', 'avc1.4d0028', 'avc1.42e028'];
52+
53+const even = (x: number): number => 2 * Math.floor(x / 2);
54+
55+interface Layout {
56+ /** Sphere panel edge, px. Everything else scales by u = sphere/768. */
57+ sphere: number;
58+ u: number;
59+ /** Colorbar column to the right of each sphere, like the app's. */
60+ gutter: number;
61+ captionH: number;
62+ width: number;
63+ height: number;
64+}
65+
66+/** Sized from the live canvases: the movie matches what is on screen, capped
67+ * so the H.264 level and file size stay reasonable. Even dimensions, as
68+ * 4:2:0 encoders require. */
69+const layoutFor = (panels: MoviePanel[]): Layout => {
70+ const src = Math.min(...panels.map((p) => p.canvas.width));
71+ const sphere = even(Math.max(240, Math.min(768, src)));
72+ const u = sphere / 768;
73+ const gutter = even(Math.round(72 * u));
74+ const captionH = even(Math.round(64 * u));
75+ return {
76+ sphere,
77+ u,
78+ gutter,
79+ captionH,
80+ width: panels.length * (sphere + gutter),
81+ height: sphere + captionH,
82+ };
83+};
84+
85+export class MovieRecorder {
86+ #panels: MoviePanel[];
87+ #title: string;
88+ #subtitle: string;
89+ #speed: number;
90+ #fps: number;
91+ #lastKeyUs = 0;
92+ #layout: Layout;
93+ #canvas: HTMLCanvasElement;
94+ #ctx: CanvasRenderingContext2D;
95+ #muxer: Muxer<ArrayBufferTarget>;
96+ #encoder: VideoEncoder;
97+ #frames = 0;
98+ #error: unknown = null;
99+ // Page theme, sampled at creation so the movie matches light/dark mode.
100+ #bg: string;
101+ #ink: string;
102+ #ink2: string;
103+ #line: string;
104+ #sphereBg: string;
105+
106+ static async create(opts: MovieOptions): Promise<MovieRecorder> {
107+ if (typeof VideoEncoder === 'undefined') {
108+ throw new Error('WebCodecs is not available in this browser');
109+ }
110+ const layout = layoutFor(opts.panels);
111+ const fps = Math.max(1, Math.round(opts.fps));
112+ const config = {
113+ width: layout.width,
114+ height: layout.height,
115+ // ~0.15 bits per pixel per frame reads as visually lossless here
116+ bitrate: Math.min(
117+ 12e6,
118+ Math.max(2e6, Math.round(layout.width * layout.height * fps * 0.15)),
119+ ),
120+ framerate: fps,
121+ };
122+ for (const codec of H264_CANDIDATES) {
123+ const { supported } = await VideoEncoder.isConfigSupported({ codec, ...config });
124+ if (supported) return new MovieRecorder(opts, layout, { codec, ...config });
125+ }
126+ throw new Error('no supported H.264 encoder configuration');
127+ }
128+
129+ private constructor(opts: MovieOptions, layout: Layout, config: VideoEncoderConfig) {
130+ this.#panels = opts.panels;
131+ this.#title = opts.title;
132+ this.#subtitle = opts.subtitle;
133+ this.#speed = opts.speed;
134+ this.#fps = Math.max(1, opts.fps);
135+ this.#layout = layout;
136+
137+ const css = getComputedStyle(document.documentElement);
138+ const themeVar = (name: string, fallback: string): string =>
139+ css.getPropertyValue(name).trim() || fallback;
140+ this.#bg = themeVar('--bg', '#ffffff');
141+ this.#ink = themeVar('--ink', '#1f2328');
142+ this.#ink2 = themeVar('--ink-2', '#57606a');
143+ this.#line = themeVar('--line', '#d0d7de');
144+ this.#sphereBg = themeVar('--sphere-bg', '#f4f6f8');
145+
146+ this.#canvas = document.createElement('canvas');
147+ this.#canvas.width = layout.width;
148+ this.#canvas.height = layout.height;
149+ const ctx = this.#canvas.getContext('2d');
150+ if (!ctx) throw new Error('no 2d context for the movie canvas');
151+ this.#ctx = ctx;
152+
153+ this.#muxer = new Muxer({
154+ target: new ArrayBufferTarget(),
155+ video: {
156+ codec: 'avc',
157+ width: layout.width,
158+ height: layout.height,
159+ frameRate: Math.max(1, Math.round(opts.fps)),
160+ },
161+ fastStart: 'in-memory',
162+ });
163+ this.#encoder = new VideoEncoder({
164+ output: (chunk, meta) => this.#muxer.addVideoChunk(chunk, meta),
165+ error: (e) => (this.#error = e),
166+ });
167+ this.#encoder.configure(config);
168+ }
169+
170+ /**
171+ * Composite and encode one frame. The compositing happens synchronously, in
172+ * the caller's task; the await is only encoder backpressure, so a solver
173+ * that outruns the encoder does not pile frames up in its queue.
174+ */
175+ async addFrame(t: number, bars: MovieBar[]): Promise<void> {
176+ if (this.#error) throw this.#error;
177+ this.#compose(t, bars);
178+ const timestamp = Math.round((t / this.#speed) * 1e6);
179+ const frame = new VideoFrame(this.#canvas, {
180+ timestamp,
181+ duration: Math.round(1e6 / this.#fps),
182+ });
183+ // a keyframe every ~2 s of video keeps the file seekable without bloat
184+ const keyFrame = this.#frames === 0 || timestamp - this.#lastKeyUs >= 2e6;
185+ if (keyFrame) this.#lastKeyUs = timestamp;
186+ this.#encoder.encode(frame, { keyFrame });
187+ frame.close();
188+ this.#frames++;
189+ while (this.#encoder.encodeQueueSize > 4) {
190+ await new Promise((r) => this.#encoder.addEventListener('dequeue', r, { once: true }));
191+ }
192+ }
193+
194+ async finish(): Promise<Blob> {
195+ await this.#encoder.flush();
196+ if (this.#error) throw this.#error;
197+ this.#encoder.close();
198+ this.#muxer.finalize();
199+ return new Blob([this.#muxer.target.buffer], { type: 'video/mp4' });
200+ }
201+
202+ cancel(): void {
203+ if (this.#encoder.state !== 'closed') this.#encoder.close();
204+ }
205+
206+ // ---------------------------------------------------------------- drawing
207+ #compose(t: number, bars: MovieBar[]): void {
208+ const { sphere, gutter } = this.#layout;
209+ const ctx = this.#ctx;
210+ ctx.fillStyle = this.#bg;
211+ ctx.fillRect(0, 0, this.#layout.width, this.#layout.height);
212+ this.#panels.forEach((panel, k) => {
213+ const x = k * (sphere + gutter);
214+ ctx.drawImage(panel.canvas, x, 0, sphere, sphere);
215+ ctx.fillStyle = this.#sphereBg;
216+ ctx.fillRect(x + sphere, 0, gutter, sphere);
217+ this.#drawBar(x + sphere, bars[k]);
218+ this.#drawTag(x, panel.label);
219+ });
220+ this.#drawCaption(t);
221+ }
222+
223+ #drawBar(x0: number, bar: MovieBar): void {
224+ const { sphere, u, gutter } = this.#layout;
225+ const ctx = this.#ctx;
226+ const w = Math.round(18 * u);
227+ const h = Math.round(0.55 * sphere);
228+ const bx = Math.round(x0 + (gutter - w) / 2);
229+ const by = Math.round((sphere - h) / 2);
230+ for (let y = 0; y < h; y++) {
231+ const [r, g, b] = bar.cmap(1 - y / (h - 1));
232+ ctx.fillStyle = `rgb(${r},${g},${b})`;
233+ ctx.fillRect(bx, by + y, w, 1);
234+ }
235+ ctx.strokeStyle = this.#line;
236+ ctx.strokeRect(bx + 0.5, by + 0.5, w - 1, h - 1);
237+ ctx.fillStyle = this.#ink2;
238+ ctx.font = `${Math.round(13 * u)}px system-ui, sans-serif`;
239+ ctx.textAlign = 'center';
240+ ctx.textBaseline = 'bottom';
241+ ctx.fillText(fmtValue(bar.hi), x0 + gutter / 2, by - 6 * u);
242+ ctx.textBaseline = 'top';
243+ ctx.fillText(fmtValue(bar.lo), x0 + gutter / 2, by + h + 6 * u);
244+ }
245+
246+ /** The species name, as the app's floating tag: white on a dark pill. */
247+ #drawTag(x0: number, label: string): void {
248+ const { u } = this.#layout;
249+ const ctx = this.#ctx;
250+ const size = Math.round(20 * u);
251+ ctx.font = `600 ${size}px system-ui, sans-serif`;
252+ const tw = ctx.measureText(label).width;
253+ const padX = 12 * u;
254+ const padY = 4 * u;
255+ const bx = x0 + 12 * u;
256+ const by = 10 * u;
257+ const bh = size + 2 * padY;
258+ ctx.fillStyle = 'rgba(0, 0, 0, 0.45)';
259+ ctx.beginPath();
260+ ctx.roundRect(bx, by, tw + 2 * padX, bh, bh / 2);
261+ ctx.fill();
262+ ctx.fillStyle = '#fff';
263+ ctx.textAlign = 'left';
264+ ctx.textBaseline = 'middle';
265+ ctx.fillText(label, bx + padX, by + bh / 2 + u);
266+ }
267+
268+ #drawCaption(t: number): void {
269+ const { sphere, u, captionH, width } = this.#layout;
270+ const ctx = this.#ctx;
271+ const pad = 16 * u;
272+ ctx.strokeStyle = this.#line;
273+ ctx.beginPath();
274+ ctx.moveTo(0, sphere + 0.5);
275+ ctx.lineTo(width, sphere + 0.5);
276+ ctx.stroke();
277+ ctx.textBaseline = 'middle';
278+ ctx.fillStyle = this.#ink;
279+ ctx.font = `600 ${Math.round(20 * u)}px system-ui, sans-serif`;
280+ ctx.textAlign = 'left';
281+ ctx.fillText(this.#title, pad, sphere + captionH * 0.34);
282+ ctx.font = `${Math.round(20 * u)}px system-ui, sans-serif`;
283+ ctx.textAlign = 'right';
284+ ctx.fillText(
285+ `t = ${t.toFixed(2)} · ${this.#speed}×`,
286+ width - pad,
287+ sphere + captionH * 0.34,
288+ );
289+ ctx.fillStyle = this.#ink2;
290+ ctx.font = `${Math.round(14 * u)}px system-ui, sans-serif`;
291+ ctx.textAlign = 'left';
292+ ctx.fillText(this.#subtitle, pad, sphere + captionH * 0.74);
293+ }
294+}