feat: allow emmiting frames on stdout to pipe them in ffmpeg
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user