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 */
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;
23}
25/** Per-panel colorbar state for one frame. */
26export interface MovieBar {
27 cmap: ColormapFunc;
28 lo: number;
29 hi: number;
30}
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}
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 */
51const H264_CANDIDATES = ['avc1.640028', 'avc1.4d0028', 'avc1.42e028'];
53const even = (x: number): number => 2 * Math.floor(x / 2);
55interface 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}
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. */
69const 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};
85export 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;
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 }
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;
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');
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;
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 }
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 }
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 }
202 cancel(): void {
203 if (this.#encoder.state !== 'closed') this.#encoder.close();
204 }
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 }
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 }
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 }
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}