From a15e951f84784f4791e03bab0b2ee69453ef8cd6 Mon Sep 17 00:00:00 2001 From: supersurviveur Date: Mon, 28 Sep 2026 09:03:20 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20--shards=20CLI=E2=80=AFoption=20t?= =?UTF-8?q?o=20split=20an=20animation=20into=20multiple=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 5 ++- src/cli.rs | 11 +++++ src/headless.rs | 107 ++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 109 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1cc142b..be2489b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,10 @@ collects the targets; the export pipeline is rebuilt only when the does the camera: half-height geometrically (log-linear, since zoom spans many decades), center linearly through the complex plane at full `Big` precision; constants interpolate linearly. `--linear` swaps the default smoothstep -easing for constant pacing. Without `--to-iterations` (or a share link's), +easing for constant pacing. `--shards N --shard K` (1-based) renders only +the K-th of N contiguous parts (`shard_range`), still timed against the whole +animation (global `t`, global `frame-NNNNN.png` numbers; stdout streams just +that part), so separately rendered clips join seamlessly. Without `--to-iterations` (or a share link's), iteration count auto-scales with zoom depth per frame (same `auto_iteration_count` the interactive app uses while zooming). `--to-yaw`/`--to-pitch` (degrees, from `--yaw`/`--pitch`, yaw unwrapped so diff --git a/src/cli.rs b/src/cli.rs index b4e23f9..0107a10 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -167,6 +167,17 @@ pub struct Cli { #[arg(long)] pub linear: bool, + /// Split the animation into N equal parts (use with --shard) to render + /// it across several runs. Each part keeps the whole animation's timing + /// and easing, so the clips join seamlessly. + #[arg(long, value_name = "N")] + pub shards: Option, + + /// Which part of --shards to render, 1-based. PNG frames keep their + /// global numbering, so all shards can share one --export-path directory. + #[arg(long, value_name = "K")] + pub shard: Option, + /// 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, or any --to-* flag (--to-view, --to-julia, --to-kind, ...) to diff --git a/src/headless.rs b/src/headless.rs index 821cbfa..9dee574 100644 --- a/src/headless.rs +++ b/src/headless.rs @@ -57,6 +57,10 @@ pub fn run(cli: Cli) -> Result<(), String> { check_stdout_piped()?; } + if targets.shard.is_some() && !targets.any() { + return Err("--shard/--shards only apply to animations (give a --to-* target)".into()); + } + let mut app = FractalApp::default_state(); app.apply_cli(cli); app.set_output_size(width, height); @@ -126,6 +130,8 @@ struct AnimTargets { fps: f64, duration: Option, linear: bool, + /// `--shard K --shards N`: render only the K-th (1-based) of N parts. + shard: Option<(u32, u32)>, } impl AnimTargets { @@ -136,6 +142,16 @@ impl AnimTargets { .transpose() }; let to_cpow = pair("to-complex-power", &cli.to_complex_power)?; + let shard = match (cli.shard, cli.shards) { + (None, None) | (None, Some(1)) => None, + (Some(k), Some(n)) if (1..=n).contains(&k) => Some((k, n)), + (Some(k), Some(n)) => { + return Err(format!( + "--shard {k} is out of range 1..={n} (--shards {n})" + )); + } + _ => return Err("--shard and --shards must be given together".into()), + }; Ok(Self { to_view: cli.to_view.clone(), to_share: cli.to_share.clone(), @@ -152,6 +168,7 @@ impl AnimTargets { fps: cli.fps, duration: cli.duration, linear: cli.linear, + shard, }) } @@ -198,6 +215,16 @@ fn run_animation( if frames < 2 { return Err("animation needs at least 2 frames".into()); } + // Frames are still timed against the whole animation (`apply_frame` takes + // the global index); a shard only picks which of them this run renders. + let range = match targets.shard { + Some((_, n)) if n > frames => { + return Err(format!("--shards {n} is more than the {frames} frames")); + } + Some((k, n)) => shard_range(frames, k, n), + None => 0..frames, + }; + let (first, count) = (range.start, range.len()); let from = app.view_state().clone(); let (to, to_iterations_share) = @@ -270,7 +297,10 @@ fn run_animation( // Snapshot every frame's reference-orbit job up front (cheap: just the // parameters), so the orbits themselves can be computed in parallel. - let jobs: Vec = (0..frames) + // `jobs[j]` is global frame `first + j`; the pipeline below works in + // local indices `j`, so the stdout writer's ordering is per shard. + let jobs: Vec = range + .clone() .map(|i| { apply_frame(&mut app, i); app.reference_job() @@ -305,7 +335,14 @@ fn run_animation( error.lock().unwrap().get_or_insert(e); }; - eprintln!("rendering {frames} frames ({width}×{height}) on {threads} threads…"); + if let Some((k, n)) = targets.shard { + eprintln!( + "shard {k}/{n}: frames {}–{} of {frames}", + range.start + 1, + range.end + ); + } + eprintln!("rendering {count} frames ({width}×{height}) on {threads} threads…"); let (png_tx, png_rx) = mpsc::sync_channel::<(usize, Vec, u32, bool)>(threads * 2); let png_rx = Mutex::new(png_rx); let (raw_tx, raw_rx) = mpsc::sync_channel::<(usize, Vec)>(threads * 2); @@ -349,7 +386,7 @@ fn run_animation( } next += 1; saved.store(next, Ordering::Relaxed); - eprint!("\r[{next:>4}/{frames}] streamed"); + eprint!("\r[{next:>4}/{count}] streamed"); } } if let Err(e) = out.flush() { @@ -383,13 +420,13 @@ fn run_animation( } let png = encode_png(&padded, width, height, bpr, swap_rb, png::Compression::Fast); - let path = format!("{out_dir}/frame-{:05}.png", i + 1); + let path = format!("{out_dir}/frame-{:05}.png", first as usize + i + 1); if let Err(e) = std::fs::write(&path, &png) { fail(format!("save failed: {e}")); continue; } let done = saved.fetch_add(1, Ordering::Relaxed) + 1; - eprint!("\r[{done:>4}/{frames}] saved"); + eprint!("\r[{done:>4}/{count}] saved"); } }); } @@ -402,7 +439,7 @@ fn run_animation( if failed.load(Ordering::Relaxed) { break; } - apply_frame(&mut app, i as u32); + apply_frame(&mut app, first + i as u32); app.finish_reference(jobs[i].clone(), points); let uniforms = app.make_uniforms(aspect, height as f64); @@ -435,18 +472,27 @@ fn run_animation( return Err(e); } let saved = saved.into_inner(); - if saved != frames as usize { - return Err(format!("only {saved} of {frames} frames were rendered")); + if saved != count { + return Err(format!("only {saved} of {count} frames were rendered")); } if stream { - eprintln!("streamed {frames} frames ({width}×{height})"); + eprintln!("streamed {count} frames ({width}×{height})"); return Ok(()); } - 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" - ); + println!("saved {count} frames to {out_dir}/ ({width}×{height})"); + if targets.shard.is_some() { + println!( + "tip: once every shard is rendered into {out_dir}/, they form the full sequence; \ + this shard alone: ffmpeg -framerate {fps} -start_number {} -i {out_dir}/frame-%05d.png \ + -frames:v {count} -c:v libx264 -pix_fmt yuv420p out.mp4", + range.start + 1 + ); + } else { + println!( + "tip: ffmpeg -framerate {fps} -i {out_dir}/frame-%05d.png -c:v libx264 -pix_fmt yuv420p out.mp4" + ); + } Ok(()) } @@ -480,6 +526,14 @@ fn parse_animation_target( ))) } +/// Global frame indices of shard `shard` (1-based) out of `shards` equal +/// parts of a `frames`-frame animation. Consecutive shards tile `0..frames` +/// with no gap or overlap. +fn shard_range(frames: u32, shard: u32, shards: u32) -> std::ops::Range { + let bound = |k: u32| (k as u64 * frames as u64 / shards as u64) as u32; + bound(shard - 1)..bound(shard) +} + /// 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) @@ -504,3 +558,30 @@ async fn request_device() -> Result<(wgpu::Device, wgpu::Queue), String> { .await .map_err(|e| format!("failed to create device: {e}")) } + +#[cfg(test)] +mod tests { + use super::shard_range; + + #[test] + fn shards_tile_all_frames() { + for frames in [2, 3, 10, 97, 1000, u32::MAX] { + for shards in [1, 2, 3, 7, 10] { + if shards > frames { + continue; + } + let mut next = 0; + for k in 1..=shards { + let r = shard_range(frames, k, shards); + assert_eq!( + r.start, next, + "gap/overlap at shard {k}/{shards} of {frames}" + ); + assert!(!r.is_empty(), "empty shard {k}/{shards} of {frames}"); + next = r.end; + } + assert_eq!(next, frames); + } + } + } +}