Movie export: selectable output resolution
A resolution select (480-1440 px per sphere panel, default 768) in the
export bar. During recording each scene's drawing buffer is set to
exactly that size — independent of the window size and devicePixelRatio
— and restored afterwards, so the export is crisp at the chosen
resolution even on a small screen. The H.264 level follows the frame
area (4.0 up to 1080p, 5.1 beyond) and the bitrate ceiling rises to
match.
4 changed files+64−15
index.htmlmodified+9−0View file
@@ -211,6 +211,15 @@
211211 <option value="20">20×</option>
212212 </select>
213213 </label>
214+ <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
215+ <select id="movieres">
216+ <option value="480">480</option>
217+ <option value="640">640</option>
218+ <option value="768" selected>768</option>
219+ <option value="1080">1080</option>
220+ <option value="1440">1440</option>
221+ </select>
222+ </label>
214223 <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)">
215224 <input type="checkbox" id="movierotate" checked /> auto-rotate
216225 </label>
src/main.tsmodified+11−1View file
@@ -37,6 +37,7 @@ const elResetView = $<HTMLButtonElement>('resetview');
3737 const elMovieToggle = $<HTMLButtonElement>('movietoggle');
3838 const elMovieBar = $('moviebar');
3939 const elMovieSpeed = $<HTMLSelectElement>('moviespeed');
40+const elMovieRes = $<HTMLSelectElement>('movieres');
4041 const elMovieRotate = $<HTMLInputElement>('movierotate');
4142 const elMovie = $<HTMLButtonElement>('movie');
4243 const elParams = $('params');
@@ -643,7 +644,8 @@ function submitSteps(n: number): void {
643644 function setMovieUi(on: boolean): void {
644645 const locked = [
645646 elModel, elLmax, elOversample, elColormap, elRunPause, elBenchmark,
646- elReseed, elRecompile, elRevert, elMovieSpeed, elMovieRotate, elMovieToggle,
647+ elReseed, elRecompile, elRevert, elMovieSpeed, elMovieRes, elMovieRotate,
648+ elMovieToggle,
647649 ];
648650 for (const el of locked) el.disabled = on;
649651 elParams.querySelectorAll('input').forEach((input) => (input.disabled = on));
@@ -688,8 +690,12 @@ async function recordMovie(): Promise<void> {
688690 if (gen !== generation || !session) return;
689691 total = session.steps;
690692 const speed = Number(elMovieSpeed.value) || 10;
693+ const sphere = Number(elMovieRes.value) || 768;
691694 const rotate = elMovieRotate.checked;
692695 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);
693699 const durationS = Math.max(session.t / speed, 2 / MOVIE_FPS);
694700 const frames = Math.max(
695701 2,
@@ -710,6 +716,7 @@ async function recordMovie(): Promise<void> {
710716 subtitle,
711717 speed,
712718 fps: (frames - 1) / durationS,
719+ sphere,
713720 });
714721
715722 let finished = false;
@@ -775,6 +782,9 @@ async function recordMovie(): Promise<void> {
775782 await draw();
776783 updateStats();
777784 }
785+ if (gen === generation) {
786+ for (const s of scenes) s.restoreSize();
787+ }
778788 if (camBefore && gen === generation) {
779789 for (const s of scenes) s.setCameraState(camBefore);
780790 }
src/render/SphereScene.tsmodified+25−0View file
@@ -223,6 +223,31 @@ export class SphereScene {
223223 this.#needsRender = true;
224224 }
225225
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+
226251 dispose(): void {
227252 if (this.#animationId !== null) {
228253 cancelAnimationFrame(this.#animationId);
src/render/movie.tsmodified+19−14View file
@@ -41,14 +41,21 @@ export interface MovieOptions {
4141 speed: number;
4242 /** Effective frames per second, for encoder rate control only. */
4343 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;
4448 }
4549
4650 /**
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.
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).
5054 */
51-const H264_CANDIDATES = ['avc1.640028', 'avc1.4d0028', 'avc1.42e028'];
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+};
5259
5360 const even = (x: number): number => 2 * Math.floor(x / 2);
5461
@@ -63,12 +70,10 @@ interface Layout {
6370 height: number;
6471 }
6572
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)));
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)));
7277 const u = sphere / 768;
7378 const gutter = even(Math.round(72 * u));
7479 const captionH = even(Math.round(64 * u));
@@ -77,7 +82,7 @@ const layoutFor = (panels: MoviePanel[]): Layout => {
7782 u,
7883 gutter,
7984 captionH,
80- width: panels.length * (sphere + gutter),
85+ width: nPanels * (sphere + gutter),
8186 height: sphere + captionH,
8287 };
8388 };
@@ -107,19 +112,19 @@ export class MovieRecorder {
107112 if (typeof VideoEncoder === 'undefined') {
108113 throw new Error('WebCodecs is not available in this browser');
109114 }
110- const layout = layoutFor(opts.panels);
115+ const layout = layoutFor(opts.panels.length, opts.sphere);
111116 const fps = Math.max(1, Math.round(opts.fps));
112117 const config = {
113118 width: layout.width,
114119 height: layout.height,
115120 // ~0.15 bits per pixel per frame reads as visually lossless here
116121 bitrate: Math.min(
117- 12e6,
122+ 24e6,
118123 Math.max(2e6, Math.round(layout.width * layout.height * fps * 0.15)),
119124 ),
120125 framerate: fps,
121126 };
122- for (const codec of H264_CANDIDATES) {
127+ for (const codec of h264Candidates(layout.width * layout.height)) {
123128 const { supported } = await VideoEncoder.isConfigSupported({ codec, ...config });
124129 if (supported) return new MovieRecorder(opts, layout, { codec, ...config });
125130 }