From 449b1b289b0c70330538f907c7decb6fcb39ad70 Mon Sep 17 00:00:00 2001 From: supersurviveur Date: Sun, 20 Sep 2026 14:12:54 +0200 Subject: [PATCH] feat: add animation export --- CLAUDE.md | 15 +++++ src/app.rs | 48 +++++++++------- src/cli.rs | 41 ++++++++++++- src/headless.rs | 150 ++++++++++++++++++++++++++++++++++++++++++++++-- src/view.rs | 38 ++++++++++++ 5 files changed, 265 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 204f623..c0456c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,21 @@ default 1920×1080, `--export-path out.png`) without needing a GPU-backed window/event loop. Not yet supported with `--buddhabrot`. Run `mandelbrot --help` for the full list. +`--headless` also has an animation mode, for feeding into `ffmpeg`: add +`--to-view re,im,half_height[,iterations]` (or `--to-share `, which +only pulls position/zoom/iterations out of the link) alongside a start view +(`--view`/`--share`/`--kind`/`--julia`), plus `--frames N` or +`--fps`/`--duration`. `--export-path` then names an output *directory* of +`frame-00001.png`, `frame-00002.png`, ... instead of a single file. Only the +camera (center + half-height) is animated — kind, colors, and per-kind +constants stay fixed at whatever the start flags set. `view::interpolate_view` +does the interpolation: half-height geometrically (log-linear, since zoom +spans many decades), center linearly through the complex plane at full +`Big` precision; `--linear` swaps the default smoothstep easing for constant +pacing. Iteration count auto-scales with zoom depth per frame (same +`auto_iteration_count` the interactive app uses while zooming), overriding +any iteration count from `--view`/`--share`/`--to-view`/`--to-share`. + There's no GPU in most sandboxes: `cargo check`/`cargo test --test shader_valid` are the fast, headless way to validate a change. `cargo test` also runs but doesn't touch the GPU — the reference-orbit tests are pure CPU math (see diff --git a/src/app.rs b/src/app.rs index 550b794..72e709b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,7 +15,7 @@ use crate::fractal::{ use crate::lights::Light; use crate::view::{ Big, DEFAULT_HALF_HEIGHT, ViewState, big_from_decimal_str, big_from_f64, big_to_decimal_str, - precision_for, + parse_view_spec, precision_for, }; #[cfg(not(target_arch = "wasm32"))] use clap::Parser; @@ -545,32 +545,40 @@ impl FractalApp { /// parsed at full precision). Used by the native debug env var. #[allow(dead_code)] pub fn apply_view_spec(&mut self, spec: &str) -> bool { - let parts: Vec<&str> = spec.split(',').collect(); - if parts.len() < 3 { - return false; - } - let Ok(half_height) = parts[2].trim().parse::() else { + let Some((view, iterations)) = parse_view_spec(spec) else { return false; }; - if !(half_height > 0.0 && half_height.is_finite()) { - return false; - } - let bits = precision_for(half_height); - let (Some(re), Some(im)) = ( - big_from_decimal_str(parts[0], bits), - big_from_decimal_str(parts[1], bits), - ) else { - return false; - }; - self.view = ViewState::with_center(re, im, half_height); - if let Some(it) = parts.get(3) - && let Ok(v) = it.trim().parse::() - { + self.view = view; + if let Some(v) = iterations { self.max_iterations = v.clamp(32, MAX_REF_POINTS as u32 - 1); } true } + /// The current view (center + half-height). Used by headless animation + /// to snapshot the start of a camera path. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn view_state(&self) -> &ViewState { + &self.view + } + + /// Jump straight to `view` for the next frame, keeping every other + /// parameter (kind, colors, iteration count, ...) as-is. Used by + /// headless animation to step through interpolated keyframes. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn set_view(&mut self, view: ViewState) { + self.view = view; + } + + /// Force `max_iterations` to auto-scale with zoom depth on every + /// subsequent `compute_reference_blocking` call. Used by headless + /// animation so iteration count keeps pace with the camera zooming in, + /// the same way it does while dragging/zooming interactively. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn set_auto_iterations(&mut self, v: bool) { + self.auto_iterations = v; + } + /// Jump to a preset Mandelbrot location: decimal center (parsed at the /// precision the zoom needs), half-height, and a fitting iteration count. fn go_to_place(&mut self, re: &str, im: &str, half_height: f64, iterations: u32) { diff --git a/src/cli.rs b/src/cli.rs index df5eb2c..16a4b76 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -57,13 +57,50 @@ pub struct Cli { #[arg(long, value_name = "INDEX")] pub buddha_palette: Option, - /// Output path for --headless (default: fractal-.png). + /// Output path for --headless (default: fractal-.png). When + /// animating (--to-view/--to-share), this is a directory of + /// frame-00001.png, frame-00002.png, ... instead (default: + /// frames-/). #[arg(long, value_name = "PATH")] pub export_path: Option, + /// End view for an animation: "re,im,half_height[,iterations]", the same + /// syntax as --view. Combine with --view (or --share, --kind, --julia...) + /// for the start view; headless then renders a sequence of frames + /// interpolating the camera from start to end instead of a single PNG. + #[arg(long, value_name = "RE,IM,HALF_HEIGHT[,ITERATIONS]")] + pub to_view: Option, + + /// End view for an animation, as a share-link fragment (only the + /// position/zoom/iterations are used; alternative to --to-view for + /// pasting a location copied from the app's "Copy share link"). + #[arg(long, value_name = "FRAGMENT")] + pub to_share: Option, + + /// Number of frames to render for an animation. Alternative to --fps + + /// --duration. + #[arg(long, value_name = "N")] + pub frames: Option, + + /// Frames per second, used with --duration to compute the frame count + /// (ignored if --frames is given). Also used in the ffmpeg command + /// hint printed after rendering. + #[arg(long, value_name = "N", default_value_t = 30.0)] + pub fps: f64, + + /// Animation duration in seconds, used with --fps to compute the frame + /// count (ignored if --frames is given). + #[arg(long, value_name = "SECONDS")] + pub duration: Option, + + /// Pace animation frames linearly instead of easing in/out (smoothstep). + #[arg(long)] + pub linear: bool, + /// Run without opening a window: render the current view to a PNG and /// exit. Combine with --kind/--julia/--share/--view etc. to pick what to - /// render. Not yet supported with --buddhabrot. + /// render, or --to-view/--to-share to render an animation instead of a + /// single frame. Not yet supported with --buddhabrot. #[arg(long)] pub headless: bool, diff --git a/src/headless.rs b/src/headless.rs index e839103..7af260b 100644 --- a/src/headless.rs +++ b/src/headless.rs @@ -9,7 +9,10 @@ use eframe::egui_wgpu::wgpu; use crate::app::{FractalApp, unix_timestamp}; use crate::cli::Cli; -use crate::fractal::{ExportRender, FractalRenderer, export_to_png_blocking}; +use crate::fractal::{ExportRender, FractalRenderer, ShareState, export_to_png_blocking}; +use crate::view::{ + ViewState, big_from_decimal_str, interpolate_view, parse_view_spec, precision_for, +}; /// Cap on the output image dimension (px), to stay within GPU texture limits. const MAX_DIM: u32 = 8192 * 16; @@ -21,14 +24,37 @@ pub fn run(cli: Cli) -> Result<(), String> { let width = cli.width.clamp(16, MAX_DIM); let height = cli.height.clamp(16, MAX_DIM); - let export_path = cli - .export_path - .clone() - .unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp())); + + // These drive the animation path below; grab them before `apply_cli` + // consumes `cli` to build the start state. + let to_view = cli.to_view.clone(); + let to_share = cli.to_share.clone(); + let frames_arg = cli.frames; + let fps = cli.fps; + let duration = cli.duration; + let linear = cli.linear; + let export_path = cli.export_path.clone(); let mut app = FractalApp::default_state(); app.apply_cli(cli); + if to_view.is_some() || to_share.is_some() { + return run_animation( + app, + to_view, + to_share, + frames_arg, + fps, + duration, + linear, + width, + height, + export_path, + ); + } + + let export_path = export_path.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp())); + eprintln!("computing reference orbit…"); app.compute_reference_blocking(); @@ -62,6 +88,120 @@ pub fn run(cli: Cli) -> Result<(), String> { Ok(()) } +/// Render a sequence of frames sweeping the camera from the app's current +/// (start) view to an end view, for feeding into ffmpeg. Everything other +/// than the view (kind, colors, iteration cap policy, ...) stays fixed at +/// whatever `apply_cli` set up for the start; only the camera moves. +#[allow(clippy::too_many_arguments)] +fn run_animation( + mut app: FractalApp, + to_view: Option, + to_share: Option, + frames_arg: Option, + fps: f64, + duration: Option, + linear: bool, + width: u32, + height: u32, + export_path: Option, +) -> Result<(), String> { + let frames = match frames_arg { + Some(n) => n, + None => { + let dur = duration.ok_or("animation needs --frames, or --duration (with --fps)")?; + ((fps * dur).round() as u32).max(2) + } + }; + if frames < 2 { + return Err("animation needs at least 2 frames".into()); + } + + let to = parse_animation_target(to_view.as_deref(), to_share.as_deref())?; + let from = app.view_state().clone(); + // Iteration count auto-scales with zoom depth per frame, the same way it + // does while zooming interactively — no need to interpolate it by hand. + app.set_auto_iterations(true); + + let out_dir = export_path.unwrap_or_else(|| format!("frames-{}", unix_timestamp())); + std::fs::create_dir_all(&out_dir).map_err(|e| format!("failed to create {out_dir}: {e}"))?; + + let (device, queue) = pollster::block_on(request_device())?; + let format = wgpu::TextureFormat::Bgra8Unorm; + let renderer = FractalRenderer::new(&device, format); + let (pipeline, bind_group_layout, format) = renderer.export_handles(); + + for i in 0..frames { + let raw_t = i as f64 / (frames - 1) as f64; + let t = if linear { raw_t } else { smoothstep(raw_t) }; + app.set_view(interpolate_view(&from, &to, t)); + + eprintln!("[{:>4}/{frames}] computing reference orbit…", i + 1); + app.compute_reference_blocking(); + + let uniforms = app.make_uniforms(width as f64 / height as f64); + let er = ExportRender::new( + &device, + &queue, + pipeline.clone(), + &bind_group_layout, + format, + width, + height, + uniforms, + app.reference_points(), + app.lights(), + ); + + let png = export_to_png_blocking(&device, &queue, &er, |phase, fraction| { + eprint!( + "\r[{:>4}/{frames}] {phase} {:>3.0}%", + i + 1, + fraction * 100.0 + ); + }); + eprintln!(); + + let path = format!("{out_dir}/frame-{:05}.png", i + 1); + std::fs::write(&path, &png).map_err(|e| format!("save failed: {e}"))?; + } + + println!("saved {frames} frames to {out_dir}/ ({width}×{height})"); + println!( + "tip: ffmpeg -framerate {fps} -i {out_dir}/frame-%05d.png -c:v libx264 -pix_fmt yuv420p out.mp4" + ); + Ok(()) +} + +/// Parse `--to-view`/`--to-share` (exactly one must be set) into the end +/// view of an animation. Only position/zoom/iterations are pulled from a +/// share fragment — the rest of its state (kind, colors, ...) is ignored, so +/// pasting a link from the app doesn't unexpectedly change the fractal kind +/// mid-animation. +fn parse_animation_target( + to_view: Option<&str>, + to_share: Option<&str>, +) -> Result { + if let Some(spec) = to_view { + return parse_view_spec(spec) + .map(|(view, _)| view) + .ok_or_else(|| format!("invalid --to-view spec: {spec}")); + } + let frag = to_share.expect("run_animation only called with one of to_view/to_share set"); + let state = + ShareState::decode(frag).ok_or_else(|| format!("invalid --to-share fragment: {frag}"))?; + let bits = precision_for(state.half_height); + let re = + big_from_decimal_str(&state.center_re, bits).ok_or("invalid --to-share center (re)")?; + let im = + big_from_decimal_str(&state.center_im, bits).ok_or("invalid --to-share center (im)")?; + Ok(ViewState::with_center(re, im, state.half_height)) +} + +/// Ease-in/ease-out pacing: slow at both ends, fast through the middle. +fn smoothstep(t: f64) -> f64 { + t * t * (3.0 - 2.0 * t) +} + /// Set up a wgpu device with no surface/window attached, matching the limits /// `main::wgpu_options` requests for the windowed app (the fractal fragment /// shader needs storage buffers, which downlevel/WebGL-style limits disallow). diff --git a/src/view.rs b/src/view.rs index 97867db..b8baaf3 100644 --- a/src/view.rs +++ b/src/view.rs @@ -120,6 +120,44 @@ pub fn big_from_decimal_str(s: &str, bits: usize) -> Option { Some(dec.with_base_and_precision::<2>(bits.max(53)).value()) } +/// Parse a "re,im,half_height[,iterations]" spec (re/im decimal, parsed at +/// full precision) into a view and an optional iteration count. Shared by +/// `FractalApp::apply_view_spec` (the `--view` CLI flag) and headless +/// animation's `--to-view`. +pub fn parse_view_spec(spec: &str) -> Option<(ViewState, Option)> { + let parts: Vec<&str> = spec.split(',').collect(); + if parts.len() < 3 { + return None; + } + let half_height = parts[2].trim().parse::().ok()?; + if !(half_height > 0.0 && half_height.is_finite()) { + return None; + } + let bits = precision_for(half_height); + let re = big_from_decimal_str(parts[0], bits)?; + let im = big_from_decimal_str(parts[1], bits)?; + let iterations = parts.get(3).and_then(|s| s.trim().parse::().ok()); + Some((ViewState::with_center(re, im, half_height), iterations)) +} + +/// Interpolate between two views for an animation frame, `t` in `[0, 1]`. +/// The center moves linearly through the complex plane (at full precision); +/// the half-height interpolates geometrically (log-linear), since zoom depth +/// spans many decades and a linear sweep would crawl at the start and blow +/// past the target at the end. +pub fn interpolate_view(from: &ViewState, to: &ViewState, t: f64) -> ViewState { + let half_height = from.half_height * (to.half_height / from.half_height).powf(t); + let bits = precision_for(half_height); + let t_big = big_from_f64(t, bits); + let re0 = from.center_re.clone().with_precision(bits).value(); + let im0 = from.center_im.clone().with_precision(bits).value(); + let re1 = to.center_re.clone().with_precision(bits).value(); + let im1 = to.center_im.clone().with_precision(bits).value(); + let center_re = &re0 + &(&(&re1 - &re0) * &t_big); + let center_im = &im0 + &(&(&im1 - &im0) * &t_big); + ViewState::with_center(center_re, center_im, half_height) +} + /// Render a `Big` as a decimal string with `sig_digits` significant digits. pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String { let dec = x