From 27c5a1a603963cbae8ae4da9c0382d032b58e241 Mon Sep 17 00:00:00 2001 From: supersurviveur Date: Mon, 28 Sep 2026 08:40:00 +0200 Subject: [PATCH] feat: allow emmiting frames on stdout to pipe them in ffmpeg --- CLAUDE.md | 8 ++- src/cli.rs | 4 +- src/fractal/mod.rs | 2 +- src/fractal/renderer.rs | 24 +++++++-- src/headless.rs | 115 ++++++++++++++++++++++++++++++++++++---- 5 files changed, 135 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9534cf4..1cc142b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,13 @@ position/zoom/iterations are pulled out of the link), `--to-iterations`, and `--to-kind` (per-step formula blend via `KindMorph`, camera untouched). Anything without a target stays at its start value; colors stay fixed. `--export-path` then names an output *directory* of `frame-00001.png`, -`frame-00002.png`, ... instead of a single file. `headless.rs::AnimTargets` +`frame-00002.png`, ... instead of a single file. `--export-path -` writes +to stdout instead (refused on a terminal): the PNG for a still, or for an +animation raw RGBA8 frames in order (`unpad_rgba`, no PNG encode) for +`ffmpeg -f rawvideo -pix_fmt rgba -s WxH -r FPS -i -`. A single writer +thread reorders the frames. Its buffer is bounded by the orbit workers not +starting a frame more than `window` past the last one written, not by +blocking the writer, which could deadlock. `headless.rs::AnimTargets` collects the targets; the export pipeline is rebuilt only when the `PipelineKey` changes between frames (kind morph). `view::interpolate_view` does the camera: half-height geometrically (log-linear, since zoom spans many diff --git a/src/cli.rs b/src/cli.rs index e9532c0..b4e23f9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -81,7 +81,9 @@ pub struct Cli { /// 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-/). + /// frames-/). "-" writes to stdout: the PNG for a single + /// image, or raw RGBA8 frames in order for an animation, to pipe into + /// `ffmpeg -f rawvideo -pix_fmt rgba -s WxH -r FPS -i - ...`. #[arg(long, value_name = "PATH")] pub export_path: Option, diff --git a/src/fractal/mod.rs b/src/fractal/mod.rs index f046e58..c3db237 100644 --- a/src/fractal/mod.rs +++ b/src/fractal/mod.rs @@ -16,5 +16,5 @@ pub use renderer::PipelineKey; pub use renderer::encode_png_with_progress; pub use renderer::{ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms}; #[cfg(not(target_arch = "wasm32"))] -pub use renderer::{encode_png, export_to_png_blocking, render_readback_blocking}; +pub use renderer::{encode_png, export_to_png_blocking, render_readback_blocking, unpad_rgba}; pub use share::ShareState; diff --git a/src/fractal/renderer.rs b/src/fractal/renderer.rs index b7160d2..25134ab 100644 --- a/src/fractal/renderer.rs +++ b/src/fractal/renderer.rs @@ -1608,17 +1608,15 @@ pub fn render_readback_blocking( bytes } -/// Like [`encode_png_with_progress`], but encodes the whole image at once -/// (no progress) at the given compression level. Non-streaming, so the fast -/// `fdeflate` levels don't pay the streaming-mode size penalty. +/// Strip a readback's row padding and convert it to tightly-packed RGBA8 +/// (`width * height * 4` bytes, rows top to bottom). #[cfg(not(target_arch = "wasm32"))] -pub fn encode_png( +pub fn unpad_rgba( padded: &[u8], width: u32, height: u32, padded_bpr: u32, swap_rb: bool, - compression: png::Compression, ) -> Vec { let row = (width * 4) as usize; let mut pixels = Vec::with_capacity(row * height as usize); @@ -1636,6 +1634,22 @@ pub fn encode_png( pixels.extend_from_slice(src); } } + pixels +} + +/// Like [`encode_png_with_progress`], but encodes the whole image at once +/// (no progress) at the given compression level. Non-streaming, so the fast +/// `fdeflate` levels don't pay the streaming-mode size penalty. +#[cfg(not(target_arch = "wasm32"))] +pub fn encode_png( + padded: &[u8], + width: u32, + height: u32, + padded_bpr: u32, + swap_rb: bool, + compression: png::Compression, +) -> Vec { + let pixels = unpad_rgba(padded, width, height, padded_bpr, swap_rb); let mut out = Vec::new(); { diff --git a/src/headless.rs b/src/headless.rs index 6572959..821cbfa 100644 --- a/src/headless.rs +++ b/src/headless.rs @@ -3,9 +3,12 @@ // event loop, no worker-thread debounce (nothing to debounce for a one-shot // render); it just creates its own wgpu device, computes the reference orbit // once, and renders through the same `ExportRender` path the "Export PNG" -// button uses. +// button uses. `--export-path -` writes to stdout instead: the PNG for a +// single image, or a raw RGBA8 video stream for an animation (for piping +// into ffmpeg). -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; +use std::io::{IsTerminal, Write}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Mutex, mpsc}; @@ -13,7 +16,7 @@ use crate::app::{FractalApp, RefJob, parse_complex_pair, unix_timestamp}; use crate::cli::Cli; use crate::fractal::{ ExportRender, FractalKind, FractalRenderer, PipelineKey, ShareState, encode_png, - export_to_png_blocking, render_readback_blocking, + export_to_png_blocking, render_readback_blocking, unpad_rgba, }; use crate::view::{ ViewState, big_from_decimal_str, interpolate_f64, interpolate_view, parse_view_spec, @@ -23,6 +26,21 @@ use crate::view::{ /// Cap on the output image dimension (px), to stay within GPU texture limits. const MAX_DIM: u32 = 8192 * 16; +/// `--export-path` value meaning "write to stdout". +const STDOUT_PATH: &str = "-"; + +/// Refuse to dump binary image data onto a terminal. +fn check_stdout_piped() -> Result<(), String> { + if std::io::stdout().is_terminal() { + return Err( + "--export-path - writes binary data to stdout; pipe it somewhere \ + (e.g. `| ffmpeg ...`)" + .into(), + ); + } + Ok(()) +} + pub fn run(cli: Cli) -> Result<(), String> { if cli.buddhabrot { return Err("headless mode doesn't support --buddhabrot yet".into()); @@ -35,6 +53,9 @@ pub fn run(cli: Cli) -> Result<(), String> { // consumes `cli` to build the start state. let targets = AnimTargets::from_cli(&cli)?; let export_path = cli.export_path.clone(); + if export_path.as_deref() == Some(STDOUT_PATH) { + check_stdout_piped()?; + } let mut app = FractalApp::default_state(); app.apply_cli(cli); @@ -72,8 +93,16 @@ pub fn run(cli: Cli) -> Result<(), String> { }); eprintln!(); - std::fs::write(&export_path, &png).map_err(|e| format!("save failed: {e}"))?; - println!("saved {export_path} ({width}×{height})"); + if export_path == STDOUT_PATH { + let mut out = std::io::stdout().lock(); + out.write_all(&png) + .and_then(|()| out.flush()) + .map_err(|e| format!("writing to stdout failed: {e}"))?; + eprintln!("wrote PNG to stdout ({width}×{height})"); + } else { + std::fs::write(&export_path, &png).map_err(|e| format!("save failed: {e}"))?; + println!("saved {export_path} ({width}×{height})"); + } Ok(()) } @@ -146,7 +175,9 @@ impl AnimTargets { /// state to `targets`, for feeding into ffmpeg: the camera, iteration count, /// per-kind constants (c, p, λ, complex power) and, through a kind morph, /// the iteration formula, and the 3D camera angles. Everything else (colors, ...) stays fixed at -/// whatever `apply_cli` set up for the start. +/// whatever `apply_cli` set up for the start. With `--export-path -`, frames +/// are streamed in order to stdout as raw RGBA8 (for ffmpeg's `rawvideo` +/// demuxer) instead of being written as PNGs. fn run_animation( mut app: FractalApp, targets: AnimTargets, @@ -197,8 +228,17 @@ fn run_animation( let yaw1 = targets.to_yaw.map_or(yaw0, f32::to_radians); let pitch1 = targets.to_pitch.map_or(pitch0, f32::to_radians); + let stream = export_path.as_deref() == Some(STDOUT_PATH); 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}"))?; + if stream { + eprintln!( + "streaming raw video to stdout; ffmpeg input: \ + -f rawvideo -pix_fmt rgba -s {width}x{height} -r {fps} -i -" + ); + } else { + std::fs::create_dir_all(&out_dir) + .map_err(|e| format!("failed to create {out_dir}: {e}"))?; + } // Everything about frame `i` is a pure function of its `t`, so the app can // be put into any frame's state at any time, in any order. @@ -247,7 +287,15 @@ fn run_animation( // part at deep zoom) → this thread renders each frame on the GPU → `threads` // workers PNG-encode and write frames. Frames flow through out of order // (at most ~`threads` apart); each is written under its own index. + // + // When streaming, the last stage instead unpads frames to raw RGBA and a + // single writer thread puts them back in order before writing to stdout. + // Its reorder buffer can't apply backpressure (blocking it while waiting + // for frame `k` could stall the pipeline before `k` gets through), so the + // orbit workers bound it instead: they don't start a frame more than + // `window` ahead of the last one written. let threads = std::thread::available_parallelism().map_or(4, |n| n.get()); + let window = threads * 4; let next_job = AtomicUsize::new(0); let saved = AtomicUsize::new(0); let failed = AtomicBool::new(false); @@ -260,17 +308,24 @@ fn run_animation( eprintln!("rendering {frames} 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); std::thread::scope(|scope| { let (ref_tx, ref_rx) = mpsc::sync_channel::<(usize, crate::fractal::RefOrbit)>(threads * 2); for _ in 0..threads { let ref_tx = ref_tx.clone(); - let (jobs, next_job, failed) = (&jobs, &next_job, &failed); + let (jobs, next_job, saved, failed) = (&jobs, &next_job, &saved, &failed); scope.spawn(move || { loop { let i = next_job.fetch_add(1, Ordering::Relaxed); if i >= jobs.len() || failed.load(Ordering::Relaxed) { break; } + while stream + && i >= saved.load(Ordering::Relaxed) + window + && !failed.load(Ordering::Relaxed) + { + std::thread::sleep(std::time::Duration::from_millis(2)); + } if ref_tx.send((i, jobs[i].compute())).is_err() { break; } @@ -279,7 +334,34 @@ fn run_animation( } drop(ref_tx); + if stream { + let (saved, fail) = (&saved, &fail); + scope.spawn(move || { + let mut out = std::io::stdout().lock(); + let mut pending = BTreeMap::new(); + let mut next = 0; + for (i, raw) in raw_rx.iter() { + pending.insert(i, raw); + while let Some(raw) = pending.remove(&next) { + if let Err(e) = out.write_all(&raw) { + fail(format!("writing to stdout failed: {e}")); + return; + } + next += 1; + saved.store(next, Ordering::Relaxed); + eprint!("\r[{next:>4}/{frames}] streamed"); + } + } + if let Err(e) = out.flush() { + fail(format!("writing to stdout failed: {e}")); + } + }); + } else { + drop(raw_rx); + } + for _ in 0..threads { + let raw_tx = raw_tx.clone(); let (png_rx, out_dir, saved, failed, fail) = (&png_rx, &out_dir, &saved, &failed, &fail); scope.spawn(move || { @@ -288,15 +370,23 @@ fn run_animation( let Ok((i, padded, bpr, swap_rb)) = png_rx.lock().unwrap().recv() else { break; }; + // After a failure, keep draining (without work) until the + // GPU stage hangs up, so it can't block on a full channel. if failed.load(Ordering::Relaxed) { - break; + continue; + } + if stream { + let raw = unpad_rgba(&padded, width, height, bpr, swap_rb); + // Only fails once the writer has failed and hung up. + let _ = raw_tx.send((i, raw)); + continue; } let png = encode_png(&padded, width, height, bpr, swap_rb, png::Compression::Fast); let path = format!("{out_dir}/frame-{:05}.png", i + 1); if let Err(e) = std::fs::write(&path, &png) { fail(format!("save failed: {e}")); - break; + continue; } let done = saved.fetch_add(1, Ordering::Relaxed) + 1; eprint!("\r[{done:>4}/{frames}] saved"); @@ -335,6 +425,7 @@ fn run_animation( } } // Dropping the channel ends lets the workers drain and exit. + drop(raw_tx); drop(png_tx); drop(ref_rx); }); @@ -348,6 +439,10 @@ fn run_animation( return Err(format!("only {saved} of {frames} frames were rendered")); } + if stream { + eprintln!("streamed {frames} 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"