/ concept-collection / turing-surface
Sign in
concept-collection / turing-surface
299 lines · 10.1 KBCodeBlameHistory
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 */
11import { ArrayBufferTarget, Muxer } from 'mp4-muxer';
12import type { ColormapFunc } from './colormaps.ts';
13import { fmtValue } from './colorbar.ts';
15export 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;
25/** Per-panel colorbar state for one frame. */
26export interface MovieBar {
27 cmap: ColormapFunc;
28 lo: number;
29 hi: number;
32export 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;
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 */
55const 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};
60const even = (x: number): number => 2 * Math.floor(x / 2);
62interface 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;
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. */
75const 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};
90export 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;
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 }
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;
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');
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;
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 }
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 }
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 }
207 cancel(): void {
208 if (this.#encoder.state !== 'closed') this.#encoder.close();
209 }
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 }
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 }
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 }
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 }
moveopenescclose