Comparing changes
main is 7 commits ahead of display-improvements.
Create pull request
Merge pull request #4 from concept-collection/fix-analys-parity-no-subgroups
Jeremy Magland committed
3e80a0cFix leg_analys compile error on devices without subgroups
Jeremy Magland committed
8b93430Merge pull request #3 from concept-collection/movie-export
Jeremy Magland committed
c739e85Offer 8x display oversampling
Jeremy Magland committed
912bc9dMovie export: selectable output resolution
Jeremy Magland committed
36f75b3Movie export: recompute the run from t = 0 into a captioned MP4
Jeremy Magland committed
f168295Merge pull request #2 from concept-collection/display-improvements
Jeremy Magland committed
76ca4369 changed files+663−38
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+30−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;
@@ -186,6 +188,7 @@
186188 <option value="1">off</option>
187189 <option value="2">2×</option>
188190 <option value="4">4×</option>
191+ <option value="8">8×</option>
189192 </select>
190193 </label>
191194 <label>colormap
@@ -195,6 +198,33 @@
195198 <button id="benchmark">Benchmark</button>
196199 <button id="reseed">Re-seed</button>
197200 <button id="resetview">Reset view</button>
201+ <button id="movietoggle" title="Export the run as an MP4 movie">Export movie</button>
202+ </div>
203+ <div class="controls" id="moviebar" hidden>
204+ <label title="Playback speed: simulation-time units per second of video — 1× plays one time unit per second">movie speed
205+ <select id="moviespeed">
206+ <option value="0.1">0.1×</option>
207+ <option value="0.5">0.5×</option>
208+ <option value="1">1×</option>
209+ <option value="3">3×</option>
210+ <option value="5">5×</option>
211+ <option value="10" selected>10×</option>
212+ <option value="20">20×</option>
213+ </select>
214+ </label>
215+ <label title="Rendered size of each sphere panel, in pixels — the video frame is the panels side by side plus the caption. Independent of the window size.">resolution
216+ <select id="movieres">
217+ <option value="480">480</option>
218+ <option value="640">640</option>
219+ <option value="768" selected>768</option>
220+ <option value="1080">1080</option>
221+ <option value="1440">1440</option>
222+ </select>
223+ </label>
224+ <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)">
225+ <input type="checkbox" id="movierotate" checked /> auto-rotate
226+ </label>
227+ <button id="movie" title="Recompute the run from t = 0 and download it as an MP4 movie">Export</button>
198228 </div>
199229 <div class="controls" id="params"></div>
200230 <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+241−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,12 @@ 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 elMovieRes = $<HTMLSelectElement>('movieres');
41+const elMovieRotate = $<HTMLInputElement>('movierotate');
42+const elMovie = $<HTMLButtonElement>('movie');
3643 const elParams = $('params');
3744 const elPanels = $('panels');
3845 const elStats = $('stats');
@@ -110,6 +117,22 @@ function resolveOversample(): number {
110117 return os;
111118 }
112119
120+/**
121+ * Movie frame rate, and a cap on frames per movie. Playback speed comes from
122+ * the UI, in simulation-time units per second of video; the movie's length is
123+ * the run's t at that speed, and the frame count follows from it — capped by
124+ * the run's own step count (a step is at most one frame) and by
125+ * MOVIE_MAX_FRAMES to bound encode time and file size. Frame timestamps are
126+ * derived from simulation time, so a capped movie keeps its duration and
127+ * speed exactly, at a lower effective frame rate.
128+ */
129+const MOVIE_FPS = 30;
130+const MOVIE_MAX_FRAMES = 3600;
131+
132+/** Movie auto-rotation: camera revolutions per second of video. Measured in
133+ * video time, so the orbit pace on screen is the same at every export speed. */
134+const MOVIE_ROTATE_RPS = 1 / 120;
135+
113136 // ---------------------------------------------------------------- state
114137 let device: GPUDevice | null = null;
115138 let session: ModelSession | null = null;
@@ -130,6 +153,8 @@ let seed = 1;
130153 let running = false;
131154 let adapterName = '';
132155 let pumping = false;
156+let movieBusy = false;
157+let movieCancel = false;
133158 let solverMs = 0;
134159 let frameMs = 0;
135160 let lastMeasure = 0;
@@ -226,6 +251,13 @@ elReseed.addEventListener('click', () => {
226251 elResetView.addEventListener('click', () => {
227252 for (const s of scenes) s.resetCamera();
228253 });
254+elMovieToggle.addEventListener('click', () => {
255+ elMovieBar.hidden = !elMovieBar.hidden;
256+});
257+elMovie.addEventListener('click', () => {
258+ if (movieBusy) movieCancel = true;
259+ else void recordMovie();
260+});
229261
230262 elRecompile.addEventListener('click', () => {
231263 editedSource = editor.value;
@@ -545,40 +577,221 @@ async function pump(): Promise<void> {
545577 * These are ordinary steps: the simulation advances by them.
546578 */
547579 async function benchmark(): Promise<void> {
548- if (!session) return;
580+ if (!session || movieBusy) return;
549581 setRunning(false);
550582 const BATCH = 32;
551583 const DURATION_MS = 2000;
552584 elBenchResult.textContent = 'benchmarking…';
553- await nextFrame();
585+ // A movie started mid-benchmark would replay while this loop still steps.
586+ elMovie.disabled = true;
587+ try {
588+ await nextFrame();
589+
590+ const gen = generation;
591+ const perStep: number[] = [];
592+ const t0 = performance.now();
593+ while (performance.now() - t0 < DURATION_MS) {
594+ const b0 = performance.now();
595+ session.step(BATCH);
596+ await session.sync();
597+ if (gen !== generation) return;
598+ perStep.push((performance.now() - b0) / BATCH);
599+ }
554600
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);
601+ const mean = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length;
602+ const all = mean(perStep);
603+ const best = Math.min(...perStep);
604+ const third = Math.max(1, Math.floor(perStep.length / 3));
605+ const first = mean(perStep.slice(0, third));
606+ const last = mean(perStep.slice(-third));
607+ const steps = perStep.length * BATCH;
608+
609+ elBenchResult.innerHTML =
610+ `sustained solver: <b>${all.toFixed(2)} ms/step</b> ` +
611+ `(${(1000 / all).toFixed(0)} steps/s) · best ${best.toFixed(2)} · ` +
612+ `ramp ${(first / last).toFixed(2)}× (${first.toFixed(2)} → ${last.toFixed(2)}) · ` +
613+ `${steps} steps in batches of ${BATCH} · ` +
614+ `compare with <code>npm run bench -- --lmax ${session.cfg.lmax}</code>`;
615+ await draw();
616+ updateStats();
617+ } finally {
618+ elMovie.disabled = false;
564619 }
620+}
565621
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();
622+// ---------------------------------------------------------------- movie
623+function saveBlob(blob: Blob, filename: string): void {
624+ const url = URL.createObjectURL(blob);
625+ const a = document.createElement('a');
626+ a.href = url;
627+ a.download = filename;
628+ a.click();
629+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
630+}
631+
632+/** Submit `n` steps in bounded command buffers — a single buffer encoding
633+ * many thousands of steps can exhaust the encoder. */
634+function submitSteps(n: number): void {
635+ while (n > 0 && session) {
636+ const chunk = Math.min(512, n);
637+ session.step(chunk);
638+ n -= chunk;
639+ }
640+}
641+
642+/** While recording, lock everything that could change the run mid-replay;
643+ * the Movie button itself becomes the cancel button. */
644+function setMovieUi(on: boolean): void {
645+ const locked = [
646+ elModel, elLmax, elOversample, elColormap, elRunPause, elBenchmark,
647+ elReseed, elRecompile, elRevert, elMovieSpeed, elMovieRes, elMovieRotate,
648+ elMovieToggle,
649+ ];
650+ for (const el of locked) el.disabled = on;
651+ elParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
652+ elMovie.textContent = on ? 'Cancel · 0%' : 'Export';
653+}
654+
655+/**
656+ * Recompute the run from t = 0 and download it as an MP4.
657+ *
658+ * The movie is not a recording of what already happened — it is the same
659+ * trajectory recomputed: same seed, same source, and the *current* parameters
660+ * and colormap throughout. Determinism makes this exact: after the replay the
661+ * state is where it was, so the one session is reused and the app resumes as
662+ * if nothing happened. Frames are composited from the live panels, so the
663+ * movie shows the spheres at the current camera orientation — and the replay
664+ * doubles as the progress display, since it is visible on screen.
665+ */
666+async function recordMovie(): Promise<void> {
667+ if (!session || movieBusy) return;
668+ if (session.steps === 0) {
669+ elMovie.textContent = 'run first';
670+ setTimeout(() => (elMovie.textContent = 'Export'), 1200);
671+ return;
672+ }
673+ movieBusy = true;
674+ movieCancel = false;
675+ const gen = generation;
676+ setMovieUi(true);
677+ let wasRunning = false;
678+ let total = 0;
679+ let done = 0;
680+ let seeded = false;
681+ let camBefore: ReturnType<SphereScene['cameraState']> | undefined;
682+ try {
683+ // An in-flight display-grid swap replaces the scenes whose canvases the
684+ // recorder captures, and resumes the run when it lands — let it finish.
685+ await viewChange;
686+ if (gen !== generation || !session) return;
687+ wasRunning = running;
688+ setRunning(false);
689+ while (pumping) await nextFrame(); // let an in-flight live frame drain
690+ if (gen !== generation || !session) return;
691+ total = session.steps;
692+ const speed = Number(elMovieSpeed.value) || 10;
693+ const sphere = Number(elMovieRes.value) || 768;
694+ const rotate = elMovieRotate.checked;
695+ if (rotate) camBefore = scenes[0]?.cameraState();
696+ // Render the scenes at exactly the chosen resolution for the recording —
697+ // independent of the window size — and restore afterwards.
698+ for (const s of scenes) s.captureSize(sphere);
699+ const durationS = Math.max(session.t / speed, 2 / MOVIE_FPS);
700+ const frames = Math.max(
701+ 2,
702+ Math.min(Math.round(durationS * MOVIE_FPS) + 1, total + 1, MOVIE_MAX_FRAMES),
703+ );
704+ /** The step index captured as frame `i`; both endpoints land exactly. */
705+ const stepAt = (i: number): number => Math.round((i * total) / (frames - 1));
706+
707+ const title =
708+ (presets.find((p) => p.key === elModel.value)?.label ?? model.label) +
709+ (editedSource !== null ? ' (edited)' : '');
710+ const subtitle = model.params
711+ .map((spec) => `${spec.label} ${fmtValue(params[spec.key])}`)
712+ .join(' · ');
713+ const rec = await MovieRecorder.create({
714+ panels: model.species.map((label, k) => ({ canvas: scenes[k].canvas, label })),
715+ title,
716+ subtitle,
717+ speed,
718+ fps: (frames - 1) / durationS,
719+ sphere,
720+ });
721+
722+ let finished = false;
723+ try {
724+ // Reset the color-range smoothing as a re-seed does, so the shading
725+ // evolves in the movie the way it did live.
726+ session.seed(seed);
727+ seeded = true;
728+ for (const r of ranges) {
729+ r.lo = NaN;
730+ r.hi = NaN;
731+ }
732+ const cmap = colormaps[elColormap.value] ?? colormaps.viridis;
733+ let lastVideoS = 0;
734+ for (let frame = 0; ; ) {
735+ await draw();
736+ if (gen !== generation) return;
737+ if (movieCancel) break;
738+ if (rotate) {
739+ // Advance the orbit by this frame's share of video time; siblings
740+ // follow scenes[0] through the usual camera sync.
741+ const videoS = session.t / speed;
742+ scenes[0]?.orbitBy(2 * Math.PI * MOVIE_ROTATE_RPS * (videoS - lastVideoS));
743+ lastVideoS = videoS;
744+ }
745+ for (const s of scenes) s.renderNow();
746+ await rec.addFrame(
747+ session.t,
748+ model.species.map((_, k) => ({ cmap, lo: ranges[k].lo, hi: ranges[k].hi })),
749+ );
750+ if (++frame >= frames) {
751+ finished = true;
752+ break;
753+ }
754+ const target = stepAt(frame);
755+ submitSteps(target - done);
756+ done = target;
757+ elMovie.textContent = `Cancel · ${Math.round((100 * done) / total)}%`;
758+ }
759+ if (finished) {
760+ const blob = await rec.finish();
761+ saveBlob(
762+ blob,
763+ `turing-sphere-${model.key}-t${session.t.toFixed(2)}-${speed}x.mp4`,
764+ );
765+ }
766+ } finally {
767+ if (!finished) rec.cancel();
768+ }
769+ } catch (e) {
770+ elErr.textContent = `movie: ${e instanceof Error ? e.message : e}`;
771+ } finally {
772+ // A cancelled replay stopped short of where the run was; step the
773+ // remainder — determinism makes this land exactly there.
774+ if (seeded && gen === generation && session) {
775+ while (done < total && gen === generation && session) {
776+ const n = Math.min(4096, total - done);
777+ submitSteps(n);
778+ done += n;
779+ elMovie.textContent = `restoring · ${Math.round((100 * done) / total)}%`;
780+ await session.sync();
781+ }
782+ await draw();
783+ updateStats();
784+ }
785+ if (gen === generation) {
786+ for (const s of scenes) s.restoreSize();
787+ }
788+ if (camBefore && gen === generation) {
789+ for (const s of scenes) s.setCameraState(camBefore);
790+ }
791+ movieBusy = false;
792+ setMovieUi(false);
793+ if (gen === generation) setRunning(wasRunning);
794+ }
582795 }
583796
584797 // ---------------------------------------------------------------- boot
src/render/SphereScene.tsmodified+50−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 {
@@ -198,6 +223,31 @@ export class SphereScene {
198223 this.#needsRender = true;
199224 }
200225
226+ /**
227+ * Set the drawing buffer to an exact square pixel size, independent of the
228+ * container and devicePixelRatio — for capturing at a chosen resolution.
229+ * The canvas keeps its CSS sizing, so on screen it just rescales. Undo with
230+ * restoreSize().
231+ */
232+ captureSize(px: number): void {
233+ this.#renderer.setPixelRatio(1);
234+ this.#renderer.setSize(px, px, false);
235+ this.#camera.aspect = 1;
236+ this.#camera.updateProjectionMatrix();
237+ this.#needsRender = true;
238+ }
239+
240+ /** Return from captureSize() to the container-driven buffer size. */
241+ restoreSize(): void {
242+ this.#renderer.setPixelRatio(window.devicePixelRatio || 1);
243+ if (this.#lastW > 0 && this.#lastH > 0) {
244+ this.#renderer.setSize(this.#lastW, this.#lastH, false);
245+ this.#camera.aspect = this.#lastW / Math.max(1, this.#lastH);
246+ this.#camera.updateProjectionMatrix();
247+ }
248+ this.#needsRender = true;
249+ }
250+
201251 dispose(): void {
202252 if (this.#animationId !== null) {
203253 cancelAnimationFrame(this.#animationId);
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+299−0View file
@@ -0,0 +1,299 @@
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+ /** Rendered edge of each sphere panel, px. The caller renders the scene
45+ * canvases at this size; the frame is the panels side by side plus the
46+ * caption bar. */
47+ sphere: number;
48+}
49+
50+/**
51+ * H.264 profile candidates: High, then Main, then Constrained Baseline —
52+ * Chrome's software fallback encoder supports only the last. The level covers
53+ * the frame area: 4.0 up to 1080p at 30 fps, 5.1 beyond (large exports).
54+ */
55+const h264Candidates = (pixels: number): string[] => {
56+ const level = pixels <= 1920 * 1080 ? '28' : '33';
57+ return ['avc1.6400', 'avc1.4d00', 'avc1.42e0'].map((p) => p + level);
58+};
59+
60+const even = (x: number): number => 2 * Math.floor(x / 2);
61+
62+interface Layout {
63+ /** Sphere panel edge, px. Everything else scales by u = sphere/768. */
64+ sphere: number;
65+ u: number;
66+ /** Colorbar column to the right of each sphere, like the app's. */
67+ gutter: number;
68+ captionH: number;
69+ width: number;
70+ height: number;
71+}
72+
73+/** Sized from the caller's resolution choice, clamped to what H.264 encoders
74+ * comfortably handle. Even dimensions, as 4:2:0 encoders require. */
75+const layoutFor = (nPanels: number, spherePx: number): Layout => {
76+ const sphere = even(Math.max(240, Math.min(1600, spherePx)));
77+ const u = sphere / 768;
78+ const gutter = even(Math.round(72 * u));
79+ const captionH = even(Math.round(64 * u));
80+ return {
81+ sphere,
82+ u,
83+ gutter,
84+ captionH,
85+ width: nPanels * (sphere + gutter),
86+ height: sphere + captionH,
87+ };
88+};
89+
90+export class MovieRecorder {
91+ #panels: MoviePanel[];
92+ #title: string;
93+ #subtitle: string;
94+ #speed: number;
95+ #fps: number;
96+ #lastKeyUs = 0;
97+ #layout: Layout;
98+ #canvas: HTMLCanvasElement;
99+ #ctx: CanvasRenderingContext2D;
100+ #muxer: Muxer<ArrayBufferTarget>;
101+ #encoder: VideoEncoder;
102+ #frames = 0;
103+ #error: unknown = null;
104+ // Page theme, sampled at creation so the movie matches light/dark mode.
105+ #bg: string;
106+ #ink: string;
107+ #ink2: string;
108+ #line: string;
109+ #sphereBg: string;
110+
111+ static async create(opts: MovieOptions): Promise<MovieRecorder> {
112+ if (typeof VideoEncoder === 'undefined') {
113+ throw new Error('WebCodecs is not available in this browser');
114+ }
115+ const layout = layoutFor(opts.panels.length, opts.sphere);
116+ const fps = Math.max(1, Math.round(opts.fps));
117+ const config = {
118+ width: layout.width,
119+ height: layout.height,
120+ // ~0.15 bits per pixel per frame reads as visually lossless here
121+ bitrate: Math.min(
122+ 24e6,
123+ Math.max(2e6, Math.round(layout.width * layout.height * fps * 0.15)),
124+ ),
125+ framerate: fps,
126+ };
127+ for (const codec of h264Candidates(layout.width * layout.height)) {
128+ const { supported } = await VideoEncoder.isConfigSupported({ codec, ...config });
129+ if (supported) return new MovieRecorder(opts, layout, { codec, ...config });
130+ }
131+ throw new Error('no supported H.264 encoder configuration');
132+ }
133+
134+ private constructor(opts: MovieOptions, layout: Layout, config: VideoEncoderConfig) {
135+ this.#panels = opts.panels;
136+ this.#title = opts.title;
137+ this.#subtitle = opts.subtitle;
138+ this.#speed = opts.speed;
139+ this.#fps = Math.max(1, opts.fps);
140+ this.#layout = layout;
141+
142+ const css = getComputedStyle(document.documentElement);
143+ const themeVar = (name: string, fallback: string): string =>
144+ css.getPropertyValue(name).trim() || fallback;
145+ this.#bg = themeVar('--bg', '#ffffff');
146+ this.#ink = themeVar('--ink', '#1f2328');
147+ this.#ink2 = themeVar('--ink-2', '#57606a');
148+ this.#line = themeVar('--line', '#d0d7de');
149+ this.#sphereBg = themeVar('--sphere-bg', '#f4f6f8');
150+
151+ this.#canvas = document.createElement('canvas');
152+ this.#canvas.width = layout.width;
153+ this.#canvas.height = layout.height;
154+ const ctx = this.#canvas.getContext('2d');
155+ if (!ctx) throw new Error('no 2d context for the movie canvas');
156+ this.#ctx = ctx;
157+
158+ this.#muxer = new Muxer({
159+ target: new ArrayBufferTarget(),
160+ video: {
161+ codec: 'avc',
162+ width: layout.width,
163+ height: layout.height,
164+ frameRate: Math.max(1, Math.round(opts.fps)),
165+ },
166+ fastStart: 'in-memory',
167+ });
168+ this.#encoder = new VideoEncoder({
169+ output: (chunk, meta) => this.#muxer.addVideoChunk(chunk, meta),
170+ error: (e) => (this.#error = e),
171+ });
172+ this.#encoder.configure(config);
173+ }
174+
175+ /**
176+ * Composite and encode one frame. The compositing happens synchronously, in
177+ * the caller's task; the await is only encoder backpressure, so a solver
178+ * that outruns the encoder does not pile frames up in its queue.
179+ */
180+ async addFrame(t: number, bars: MovieBar[]): Promise<void> {
181+ if (this.#error) throw this.#error;
182+ this.#compose(t, bars);
183+ const timestamp = Math.round((t / this.#speed) * 1e6);
184+ const frame = new VideoFrame(this.#canvas, {
185+ timestamp,
186+ duration: Math.round(1e6 / this.#fps),
187+ });
188+ // a keyframe every ~2 s of video keeps the file seekable without bloat
189+ const keyFrame = this.#frames === 0 || timestamp - this.#lastKeyUs >= 2e6;
190+ if (keyFrame) this.#lastKeyUs = timestamp;
191+ this.#encoder.encode(frame, { keyFrame });
192+ frame.close();
193+ this.#frames++;
194+ while (this.#encoder.encodeQueueSize > 4) {
195+ await new Promise((r) => this.#encoder.addEventListener('dequeue', r, { once: true }));
196+ }
197+ }
198+
199+ async finish(): Promise<Blob> {
200+ await this.#encoder.flush();
201+ if (this.#error) throw this.#error;
202+ this.#encoder.close();
203+ this.#muxer.finalize();
204+ return new Blob([this.#muxer.target.buffer], { type: 'video/mp4' });
205+ }
206+
207+ cancel(): void {
208+ if (this.#encoder.state !== 'closed') this.#encoder.close();
209+ }
210+
211+ // ---------------------------------------------------------------- drawing
212+ #compose(t: number, bars: MovieBar[]): void {
213+ const { sphere, gutter } = this.#layout;
214+ const ctx = this.#ctx;
215+ ctx.fillStyle = this.#bg;
216+ ctx.fillRect(0, 0, this.#layout.width, this.#layout.height);
217+ this.#panels.forEach((panel, k) => {
218+ const x = k * (sphere + gutter);
219+ ctx.drawImage(panel.canvas, x, 0, sphere, sphere);
220+ ctx.fillStyle = this.#sphereBg;
221+ ctx.fillRect(x + sphere, 0, gutter, sphere);
222+ this.#drawBar(x + sphere, bars[k]);
223+ this.#drawTag(x, panel.label);
224+ });
225+ this.#drawCaption(t);
226+ }
227+
228+ #drawBar(x0: number, bar: MovieBar): void {
229+ const { sphere, u, gutter } = this.#layout;
230+ const ctx = this.#ctx;
231+ const w = Math.round(18 * u);
232+ const h = Math.round(0.55 * sphere);
233+ const bx = Math.round(x0 + (gutter - w) / 2);
234+ const by = Math.round((sphere - h) / 2);
235+ for (let y = 0; y < h; y++) {
236+ const [r, g, b] = bar.cmap(1 - y / (h - 1));
237+ ctx.fillStyle = `rgb(${r},${g},${b})`;
238+ ctx.fillRect(bx, by + y, w, 1);
239+ }
240+ ctx.strokeStyle = this.#line;
241+ ctx.strokeRect(bx + 0.5, by + 0.5, w - 1, h - 1);
242+ ctx.fillStyle = this.#ink2;
243+ ctx.font = `${Math.round(13 * u)}px system-ui, sans-serif`;
244+ ctx.textAlign = 'center';
245+ ctx.textBaseline = 'bottom';
246+ ctx.fillText(fmtValue(bar.hi), x0 + gutter / 2, by - 6 * u);
247+ ctx.textBaseline = 'top';
248+ ctx.fillText(fmtValue(bar.lo), x0 + gutter / 2, by + h + 6 * u);
249+ }
250+
251+ /** The species name, as the app's floating tag: white on a dark pill. */
252+ #drawTag(x0: number, label: string): void {
253+ const { u } = this.#layout;
254+ const ctx = this.#ctx;
255+ const size = Math.round(20 * u);
256+ ctx.font = `600 ${size}px system-ui, sans-serif`;
257+ const tw = ctx.measureText(label).width;
258+ const padX = 12 * u;
259+ const padY = 4 * u;
260+ const bx = x0 + 12 * u;
261+ const by = 10 * u;
262+ const bh = size + 2 * padY;
263+ ctx.fillStyle = 'rgba(0, 0, 0, 0.45)';
264+ ctx.beginPath();
265+ ctx.roundRect(bx, by, tw + 2 * padX, bh, bh / 2);
266+ ctx.fill();
267+ ctx.fillStyle = '#fff';
268+ ctx.textAlign = 'left';
269+ ctx.textBaseline = 'middle';
270+ ctx.fillText(label, bx + padX, by + bh / 2 + u);
271+ }
272+
273+ #drawCaption(t: number): void {
274+ const { sphere, u, captionH, width } = this.#layout;
275+ const ctx = this.#ctx;
276+ const pad = 16 * u;
277+ ctx.strokeStyle = this.#line;
278+ ctx.beginPath();
279+ ctx.moveTo(0, sphere + 0.5);
280+ ctx.lineTo(width, sphere + 0.5);
281+ ctx.stroke();
282+ ctx.textBaseline = 'middle';
283+ ctx.fillStyle = this.#ink;
284+ ctx.font = `600 ${Math.round(20 * u)}px system-ui, sans-serif`;
285+ ctx.textAlign = 'left';
286+ ctx.fillText(this.#title, pad, sphere + captionH * 0.34);
287+ ctx.font = `${Math.round(20 * u)}px system-ui, sans-serif`;
288+ ctx.textAlign = 'right';
289+ ctx.fillText(
290+ `t = ${t.toFixed(2)} · ${this.#speed}×`,
291+ width - pad,
292+ sphere + captionH * 0.34,
293+ );
294+ ctx.fillStyle = this.#ink2;
295+ ctx.font = `${Math.round(14 * u)}px system-ui, sans-serif`;
296+ ctx.textAlign = 'left';
297+ ctx.fillText(this.#subtitle, pad, sphere + captionH * 0.74);
298+ }
299+}
src/sht/wgsl/leg.tsmodified+7−2View file
@@ -305,8 +305,13 @@ ${
305305 var c1 = vec2f(0.0);
306306 for (var k = 0u; k < K; k++) {
307307 if (nyv[k] == 0) {
308- c0 += wfv[k] * y0v[k];
309- c1 += wfv[k] * y1v[k];
308+${
309+ half
310+ ? ` c0 += wpv[k] * y0v[k]; // even (l-m): hemispheres add
311+ c1 += wmv[k] * y1v[k]; // odd (l-m): hemispheres subtract`
312+ : ` c0 += wfv[k] * y0v[k];
313+ c1 += wfv[k] * y1v[k];`
314+ }
310315 } else if (abs(y0v[k]) > RESCALE_THR) {
311316 nyv[k] += 1;
312317 y0v[k] *= INV_SCALE;