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
|
does the camera: half-height geometrically (log-linear, since zoom spans many
|
||||||
decades), center linearly through the complex plane at full `Big` precision;
|
decades), center linearly through the complex plane at full `Big` precision;
|
||||||
constants interpolate linearly. `--linear` swaps the default smoothstep
|
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
|
iteration count auto-scales with zoom depth per frame (same
|
||||||
`auto_iteration_count` the interactive app uses while zooming).
|
`auto_iteration_count` the interactive app uses while zooming).
|
||||||
`--to-yaw`/`--to-pitch` (degrees, from `--yaw`/`--pitch`, yaw unwrapped so
|
`--to-yaw`/`--to-pitch` (degrees, from `--yaw`/`--pitch`, yaw unwrapped so
|
||||||
|
|||||||
+11
@@ -167,6 +167,17 @@ pub struct Cli {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub linear: bool,
|
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
|
/// 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, or any --to-* flag (--to-view, --to-julia, --to-kind, ...) to
|
/// render, or any --to-* flag (--to-view, --to-julia, --to-kind, ...) to
|
||||||
|
|||||||
+91
-10
@@ -57,6 +57,10 @@ pub fn run(cli: Cli) -> Result<(), String> {
|
|||||||
check_stdout_piped()?;
|
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();
|
let mut app = FractalApp::default_state();
|
||||||
app.apply_cli(cli);
|
app.apply_cli(cli);
|
||||||
app.set_output_size(width, height);
|
app.set_output_size(width, height);
|
||||||
@@ -126,6 +130,8 @@ struct AnimTargets {
|
|||||||
fps: f64,
|
fps: f64,
|
||||||
duration: Option<f64>,
|
duration: Option<f64>,
|
||||||
linear: bool,
|
linear: bool,
|
||||||
|
/// `--shard K --shards N`: render only the K-th (1-based) of N parts.
|
||||||
|
shard: Option<(u32, u32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AnimTargets {
|
impl AnimTargets {
|
||||||
@@ -136,6 +142,16 @@ impl AnimTargets {
|
|||||||
.transpose()
|
.transpose()
|
||||||
};
|
};
|
||||||
let to_cpow = pair("to-complex-power", &cli.to_complex_power)?;
|
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 {
|
Ok(Self {
|
||||||
to_view: cli.to_view.clone(),
|
to_view: cli.to_view.clone(),
|
||||||
to_share: cli.to_share.clone(),
|
to_share: cli.to_share.clone(),
|
||||||
@@ -152,6 +168,7 @@ impl AnimTargets {
|
|||||||
fps: cli.fps,
|
fps: cli.fps,
|
||||||
duration: cli.duration,
|
duration: cli.duration,
|
||||||
linear: cli.linear,
|
linear: cli.linear,
|
||||||
|
shard,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +215,16 @@ fn run_animation(
|
|||||||
if frames < 2 {
|
if frames < 2 {
|
||||||
return Err("animation needs at least 2 frames".into());
|
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 from = app.view_state().clone();
|
||||||
let (to, to_iterations_share) =
|
let (to, to_iterations_share) =
|
||||||
@@ -270,7 +297,10 @@ fn run_animation(
|
|||||||
|
|
||||||
// Snapshot every frame's reference-orbit job up front (cheap: just the
|
// Snapshot every frame's reference-orbit job up front (cheap: just the
|
||||||
// parameters), so the orbits themselves can be computed in parallel.
|
// 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| {
|
.map(|i| {
|
||||||
apply_frame(&mut app, i);
|
apply_frame(&mut app, i);
|
||||||
app.reference_job()
|
app.reference_job()
|
||||||
@@ -305,7 +335,14 @@ fn run_animation(
|
|||||||
error.lock().unwrap().get_or_insert(e);
|
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_tx, png_rx) = mpsc::sync_channel::<(usize, Vec<u8>, u32, bool)>(threads * 2);
|
||||||
let png_rx = Mutex::new(png_rx);
|
let png_rx = Mutex::new(png_rx);
|
||||||
let (raw_tx, raw_rx) = mpsc::sync_channel::<(usize, Vec<u8>)>(threads * 2);
|
let (raw_tx, raw_rx) = mpsc::sync_channel::<(usize, Vec<u8>)>(threads * 2);
|
||||||
@@ -349,7 +386,7 @@ fn run_animation(
|
|||||||
}
|
}
|
||||||
next += 1;
|
next += 1;
|
||||||
saved.store(next, Ordering::Relaxed);
|
saved.store(next, Ordering::Relaxed);
|
||||||
eprint!("\r[{next:>4}/{frames}] streamed");
|
eprint!("\r[{next:>4}/{count}] streamed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(e) = out.flush() {
|
if let Err(e) = out.flush() {
|
||||||
@@ -383,13 +420,13 @@ fn run_animation(
|
|||||||
}
|
}
|
||||||
let png =
|
let png =
|
||||||
encode_png(&padded, width, height, bpr, swap_rb, png::Compression::Fast);
|
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) {
|
if let Err(e) = std::fs::write(&path, &png) {
|
||||||
fail(format!("save failed: {e}"));
|
fail(format!("save failed: {e}"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let done = saved.fetch_add(1, Ordering::Relaxed) + 1;
|
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) {
|
if failed.load(Ordering::Relaxed) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
apply_frame(&mut app, i as u32);
|
apply_frame(&mut app, first + i as u32);
|
||||||
app.finish_reference(jobs[i].clone(), points);
|
app.finish_reference(jobs[i].clone(), points);
|
||||||
|
|
||||||
let uniforms = app.make_uniforms(aspect, height as f64);
|
let uniforms = app.make_uniforms(aspect, height as f64);
|
||||||
@@ -435,18 +472,27 @@ fn run_animation(
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
let saved = saved.into_inner();
|
let saved = saved.into_inner();
|
||||||
if saved != frames as usize {
|
if saved != count {
|
||||||
return Err(format!("only {saved} of {frames} frames were rendered"));
|
return Err(format!("only {saved} of {count} frames were rendered"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if stream {
|
if stream {
|
||||||
eprintln!("streamed {frames} frames ({width}×{height})");
|
eprintln!("streamed {count} frames ({width}×{height})");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
println!("saved {frames} frames to {out_dir}/ ({width}×{height})");
|
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!(
|
println!(
|
||||||
"tip: ffmpeg -framerate {fps} -i {out_dir}/frame-%05d.png -c:v libx264 -pix_fmt yuv420p out.mp4"
|
"tip: ffmpeg -framerate {fps} -i {out_dir}/frame-%05d.png -c:v libx264 -pix_fmt yuv420p out.mp4"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
Ok(())
|
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.
|
/// Ease-in/ease-out pacing: slow at both ends, fast through the middle.
|
||||||
fn smoothstep(t: f64) -> f64 {
|
fn smoothstep(t: f64) -> f64 {
|
||||||
t * t * (3.0 - 2.0 * t)
|
t * t * (3.0 - 2.0 * t)
|
||||||
@@ -504,3 +558,30 @@ async fn request_device() -> Result<(wgpu::Device, wgpu::Queue), String> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("failed to create device: {e}"))
|
.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