feat: add --shards CLI option to split an animation into multiple run
This commit is contained in:
@@ -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
|
||||
|
||||
+11
@@ -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<u32>,
|
||||
|
||||
/// 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<u32>,
|
||||
|
||||
/// 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
|
||||
|
||||
+94
-13
@@ -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<f64>,
|
||||
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<RefJob> = (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<RefJob> = 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<u8>, u32, bool)>(threads * 2);
|
||||
let png_rx = Mutex::new(png_rx);
|
||||
let (raw_tx, raw_rx) = mpsc::sync_channel::<(usize, Vec<u8>)>(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<u32> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user