feat: add animation export
This commit is contained in:
@@ -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
|
window/event loop. Not yet supported with `--buddhabrot`. Run
|
||||||
`mandelbrot --help` for the full list.
|
`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 <fragment>`, 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`
|
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
|
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
|
doesn't touch the GPU — the reference-orbit tests are pure CPU math (see
|
||||||
|
|||||||
+28
-20
@@ -15,7 +15,7 @@ use crate::fractal::{
|
|||||||
use crate::lights::Light;
|
use crate::lights::Light;
|
||||||
use crate::view::{
|
use crate::view::{
|
||||||
Big, DEFAULT_HALF_HEIGHT, ViewState, big_from_decimal_str, big_from_f64, big_to_decimal_str,
|
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"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
@@ -545,32 +545,40 @@ impl FractalApp {
|
|||||||
/// parsed at full precision). Used by the native debug env var.
|
/// parsed at full precision). Used by the native debug env var.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn apply_view_spec(&mut self, spec: &str) -> bool {
|
pub fn apply_view_spec(&mut self, spec: &str) -> bool {
|
||||||
let parts: Vec<&str> = spec.split(',').collect();
|
let Some((view, iterations)) = parse_view_spec(spec) else {
|
||||||
if parts.len() < 3 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let Ok(half_height) = parts[2].trim().parse::<f64>() else {
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if !(half_height > 0.0 && half_height.is_finite()) {
|
self.view = view;
|
||||||
return false;
|
if let Some(v) = iterations {
|
||||||
}
|
|
||||||
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::<u32>()
|
|
||||||
{
|
|
||||||
self.max_iterations = v.clamp(32, MAX_REF_POINTS as u32 - 1);
|
self.max_iterations = v.clamp(32, MAX_REF_POINTS as u32 - 1);
|
||||||
}
|
}
|
||||||
true
|
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
|
/// Jump to a preset Mandelbrot location: decimal center (parsed at the
|
||||||
/// precision the zoom needs), half-height, and a fitting iteration count.
|
/// 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) {
|
fn go_to_place(&mut self, re: &str, im: &str, half_height: f64, iterations: u32) {
|
||||||
|
|||||||
+39
-2
@@ -57,13 +57,50 @@ pub struct Cli {
|
|||||||
#[arg(long, value_name = "INDEX")]
|
#[arg(long, value_name = "INDEX")]
|
||||||
pub buddha_palette: Option<u32>,
|
pub buddha_palette: Option<u32>,
|
||||||
|
|
||||||
/// Output path for --headless (default: fractal-<timestamp>.png).
|
/// Output path for --headless (default: fractal-<timestamp>.png). When
|
||||||
|
/// animating (--to-view/--to-share), this is a directory of
|
||||||
|
/// frame-00001.png, frame-00002.png, ... instead (default:
|
||||||
|
/// frames-<timestamp>/).
|
||||||
#[arg(long, value_name = "PATH")]
|
#[arg(long, value_name = "PATH")]
|
||||||
pub export_path: Option<String>,
|
pub export_path: Option<String>,
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
|
||||||
|
/// Number of frames to render for an animation. Alternative to --fps +
|
||||||
|
/// --duration.
|
||||||
|
#[arg(long, value_name = "N")]
|
||||||
|
pub frames: Option<u32>,
|
||||||
|
|
||||||
|
/// 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<f64>,
|
||||||
|
|
||||||
|
/// 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
|
/// 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
|
/// 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)]
|
#[arg(long)]
|
||||||
pub headless: bool,
|
pub headless: bool,
|
||||||
|
|
||||||
|
|||||||
+145
-5
@@ -9,7 +9,10 @@ use eframe::egui_wgpu::wgpu;
|
|||||||
|
|
||||||
use crate::app::{FractalApp, unix_timestamp};
|
use crate::app::{FractalApp, unix_timestamp};
|
||||||
use crate::cli::Cli;
|
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.
|
/// Cap on the output image dimension (px), to stay within GPU texture limits.
|
||||||
const MAX_DIM: u32 = 8192 * 16;
|
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 width = cli.width.clamp(16, MAX_DIM);
|
||||||
let height = cli.height.clamp(16, MAX_DIM);
|
let height = cli.height.clamp(16, MAX_DIM);
|
||||||
let export_path = cli
|
|
||||||
.export_path
|
// These drive the animation path below; grab them before `apply_cli`
|
||||||
.clone()
|
// consumes `cli` to build the start state.
|
||||||
.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp()));
|
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();
|
let mut app = FractalApp::default_state();
|
||||||
app.apply_cli(cli);
|
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…");
|
eprintln!("computing reference orbit…");
|
||||||
app.compute_reference_blocking();
|
app.compute_reference_blocking();
|
||||||
|
|
||||||
@@ -62,6 +88,120 @@ pub fn run(cli: Cli) -> Result<(), String> {
|
|||||||
Ok(())
|
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<String>,
|
||||||
|
to_share: Option<String>,
|
||||||
|
frames_arg: Option<u32>,
|
||||||
|
fps: f64,
|
||||||
|
duration: Option<f64>,
|
||||||
|
linear: bool,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
export_path: Option<String>,
|
||||||
|
) -> 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<ViewState, String> {
|
||||||
|
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
|
/// Set up a wgpu device with no surface/window attached, matching the limits
|
||||||
/// `main::wgpu_options` requests for the windowed app (the fractal fragment
|
/// `main::wgpu_options` requests for the windowed app (the fractal fragment
|
||||||
/// shader needs storage buffers, which downlevel/WebGL-style limits disallow).
|
/// shader needs storage buffers, which downlevel/WebGL-style limits disallow).
|
||||||
|
|||||||
+38
@@ -120,6 +120,44 @@ pub fn big_from_decimal_str(s: &str, bits: usize) -> Option<Big> {
|
|||||||
Some(dec.with_base_and_precision::<2>(bits.max(53)).value())
|
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<u32>)> {
|
||||||
|
let parts: Vec<&str> = spec.split(',').collect();
|
||||||
|
if parts.len() < 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let half_height = parts[2].trim().parse::<f64>().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::<u32>().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.
|
/// Render a `Big` as a decimal string with `sig_digits` significant digits.
|
||||||
pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String {
|
pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String {
|
||||||
let dec = x
|
let dec = x
|
||||||
|
|||||||
Reference in New Issue
Block a user