feat: allow emmiting frames on stdout to pipe them in ffmpeg

This commit is contained in:
2026-09-28 08:40:00 +02:00
parent d78594af7e
commit 27c5a1a603
5 changed files with 135 additions and 18 deletions
+7 -1
View File
@@ -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). and `--to-kind` (per-step formula blend via `KindMorph`, camera untouched).
Anything without a target stays at its start value; colors stay fixed. Anything without a target stays at its start value; colors stay fixed.
`--export-path` then names an output *directory* of `frame-00001.png`, `--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 collects the targets; the export pipeline is rebuilt only when the
`PipelineKey` changes between frames (kind morph). `view::interpolate_view` `PipelineKey` changes between frames (kind morph). `view::interpolate_view`
does the camera: half-height geometrically (log-linear, since zoom spans many does the camera: half-height geometrically (log-linear, since zoom spans many
+3 -1
View File
@@ -81,7 +81,9 @@ pub struct Cli {
/// Output path for --headless (default: fractal-<timestamp>.png). When /// Output path for --headless (default: fractal-<timestamp>.png). When
/// animating (--to-view/--to-share), this is a directory of /// animating (--to-view/--to-share), this is a directory of
/// frame-00001.png, frame-00002.png, ... instead (default: /// frame-00001.png, frame-00002.png, ... instead (default:
/// frames-<timestamp>/). /// frames-<timestamp>/). "-" 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")] #[arg(long, value_name = "PATH")]
pub export_path: Option<String>, pub export_path: Option<String>,
+1 -1
View File
@@ -16,5 +16,5 @@ pub use renderer::PipelineKey;
pub use renderer::encode_png_with_progress; pub use renderer::encode_png_with_progress;
pub use renderer::{ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms}; pub use renderer::{ExportRender, FractalCallback, FractalRenderer, MAX_REF_POINTS, Uniforms};
#[cfg(not(target_arch = "wasm32"))] #[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; pub use share::ShareState;
+19 -5
View File
@@ -1608,17 +1608,15 @@ pub fn render_readback_blocking(
bytes bytes
} }
/// Like [`encode_png_with_progress`], but encodes the whole image at once /// Strip a readback's row padding and convert it to tightly-packed RGBA8
/// (no progress) at the given compression level. Non-streaming, so the fast /// (`width * height * 4` bytes, rows top to bottom).
/// `fdeflate` levels don't pay the streaming-mode size penalty.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn encode_png( pub fn unpad_rgba(
padded: &[u8], padded: &[u8],
width: u32, width: u32,
height: u32, height: u32,
padded_bpr: u32, padded_bpr: u32,
swap_rb: bool, swap_rb: bool,
compression: png::Compression,
) -> Vec<u8> { ) -> Vec<u8> {
let row = (width * 4) as usize; let row = (width * 4) as usize;
let mut pixels = Vec::with_capacity(row * height 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.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<u8> {
let pixels = unpad_rgba(padded, width, height, padded_bpr, swap_rb);
let mut out = Vec::new(); let mut out = Vec::new();
{ {
+105 -10
View File
@@ -3,9 +3,12 @@
// event loop, no worker-thread debounce (nothing to debounce for a one-shot // 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 // render); it just creates its own wgpu device, computes the reference orbit
// once, and renders through the same `ExportRender` path the "Export PNG" // 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::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Mutex, mpsc}; 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::cli::Cli;
use crate::fractal::{ use crate::fractal::{
ExportRender, FractalKind, FractalRenderer, PipelineKey, ShareState, encode_png, 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::{ use crate::view::{
ViewState, big_from_decimal_str, interpolate_f64, interpolate_view, parse_view_spec, 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. /// 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;
/// `--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> { pub fn run(cli: Cli) -> Result<(), String> {
if cli.buddhabrot { if cli.buddhabrot {
return Err("headless mode doesn't support --buddhabrot yet".into()); 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. // consumes `cli` to build the start state.
let targets = AnimTargets::from_cli(&cli)?; let targets = AnimTargets::from_cli(&cli)?;
let export_path = cli.export_path.clone(); let export_path = cli.export_path.clone();
if export_path.as_deref() == Some(STDOUT_PATH) {
check_stdout_piped()?;
}
let mut app = FractalApp::default_state(); let mut app = FractalApp::default_state();
app.apply_cli(cli); app.apply_cli(cli);
@@ -72,8 +93,16 @@ pub fn run(cli: Cli) -> Result<(), String> {
}); });
eprintln!(); eprintln!();
std::fs::write(&export_path, &png).map_err(|e| format!("save failed: {e}"))?; if export_path == STDOUT_PATH {
println!("saved {export_path} ({width}×{height})"); 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(()) Ok(())
} }
@@ -146,7 +175,9 @@ impl AnimTargets {
/// state to `targets`, for feeding into ffmpeg: the camera, iteration count, /// state to `targets`, for feeding into ffmpeg: the camera, iteration count,
/// per-kind constants (c, p, λ, complex power) and, through a kind morph, /// 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 /// 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( fn run_animation(
mut app: FractalApp, mut app: FractalApp,
targets: AnimTargets, targets: AnimTargets,
@@ -197,8 +228,17 @@ fn run_animation(
let yaw1 = targets.to_yaw.map_or(yaw0, f32::to_radians); let yaw1 = targets.to_yaw.map_or(yaw0, f32::to_radians);
let pitch1 = targets.to_pitch.map_or(pitch0, 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())); 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 // 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. // 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` // 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 // workers PNG-encode and write frames. Frames flow through out of order
// (at most ~`threads` apart); each is written under its own index. // (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 threads = std::thread::available_parallelism().map_or(4, |n| n.get());
let window = threads * 4;
let next_job = AtomicUsize::new(0); let next_job = AtomicUsize::new(0);
let saved = AtomicUsize::new(0); let saved = AtomicUsize::new(0);
let failed = AtomicBool::new(false); let failed = AtomicBool::new(false);
@@ -260,17 +308,24 @@ fn run_animation(
eprintln!("rendering {frames} frames ({width}×{height}) on {threads} threads…"); eprintln!("rendering {frames} 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);
std::thread::scope(|scope| { std::thread::scope(|scope| {
let (ref_tx, ref_rx) = mpsc::sync_channel::<(usize, crate::fractal::RefOrbit)>(threads * 2); let (ref_tx, ref_rx) = mpsc::sync_channel::<(usize, crate::fractal::RefOrbit)>(threads * 2);
for _ in 0..threads { for _ in 0..threads {
let ref_tx = ref_tx.clone(); 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 || { scope.spawn(move || {
loop { loop {
let i = next_job.fetch_add(1, Ordering::Relaxed); let i = next_job.fetch_add(1, Ordering::Relaxed);
if i >= jobs.len() || failed.load(Ordering::Relaxed) { if i >= jobs.len() || failed.load(Ordering::Relaxed) {
break; 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() { if ref_tx.send((i, jobs[i].compute())).is_err() {
break; break;
} }
@@ -279,7 +334,34 @@ fn run_animation(
} }
drop(ref_tx); 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 { for _ in 0..threads {
let raw_tx = raw_tx.clone();
let (png_rx, out_dir, saved, failed, fail) = let (png_rx, out_dir, saved, failed, fail) =
(&png_rx, &out_dir, &saved, &failed, &fail); (&png_rx, &out_dir, &saved, &failed, &fail);
scope.spawn(move || { scope.spawn(move || {
@@ -288,15 +370,23 @@ fn run_animation(
let Ok((i, padded, bpr, swap_rb)) = png_rx.lock().unwrap().recv() else { let Ok((i, padded, bpr, swap_rb)) = png_rx.lock().unwrap().recv() else {
break; 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) { 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 = 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", 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}"));
break; 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}/{frames}] saved");
@@ -335,6 +425,7 @@ fn run_animation(
} }
} }
// Dropping the channel ends lets the workers drain and exit. // Dropping the channel ends lets the workers drain and exit.
drop(raw_tx);
drop(png_tx); drop(png_tx);
drop(ref_rx); drop(ref_rx);
}); });
@@ -348,6 +439,10 @@ fn run_animation(
return Err(format!("only {saved} of {frames} frames were rendered")); 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!("saved {frames} frames to {out_dir}/ ({width}×{height})");
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"