use crate::backend::{FrameConfig, RasterError, RasterizerBackend};
use crate::scene::Scene;
use image::RgbaImage;
use rayon::prelude::*;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[derive(Debug, Clone)]
pub struct NativeRenderConfig {
pub width: u32,
pub height: u32,
pub fps: f64,
pub duration_in_frames: u32,
pub output_dir: PathBuf,
pub concurrency: Option<usize>,
}
impl NativeRenderConfig {
pub fn new(
width: u32,
height: u32,
fps: f64,
duration_in_frames: u32,
output_dir: impl Into<PathBuf>,
) -> Self {
Self {
width,
height,
fps,
duration_in_frames,
output_dir: output_dir.into(),
concurrency: None,
}
}
pub fn with_concurrency(mut self, n: usize) -> Self {
self.concurrency = Some(n);
self
}
}
#[derive(Debug, Clone)]
pub struct PipeConfig {
pub width: u32,
pub height: u32,
pub fps: f64,
pub duration_in_frames: u32,
pub output: PathBuf,
pub concurrency: Option<usize>,
pub crf: u32,
pub preset: String,
}
impl PipeConfig {
pub fn new(
width: u32,
height: u32,
fps: f64,
duration_in_frames: u32,
output: impl Into<PathBuf>,
) -> Self {
Self {
width,
height,
fps,
duration_in_frames,
output: output.into(),
concurrency: None,
crf: 18,
preset: "fast".to_string(),
}
}
pub fn with_concurrency(mut self, n: usize) -> Self {
self.concurrency = Some(n);
self
}
pub fn with_quality(mut self, crf: u32, preset: impl Into<String>) -> Self {
self.crf = crf;
self.preset = preset.into();
self
}
}
pub fn render_all_frames<F>(
backend: &dyn RasterizerBackend,
config: &NativeRenderConfig,
mut scene_fn: F,
) -> Result<Vec<PathBuf>, RasterError>
where
F: FnMut(u32) -> Scene,
{
std::fs::create_dir_all(&config.output_dir)?;
let mut paths = Vec::with_capacity(config.duration_in_frames as usize);
for frame in 0..config.duration_in_frames {
let scene = scene_fn(frame);
let frame_config = FrameConfig::new(config.width, config.height, frame, config.fps);
let img = backend.render_frame(&scene, &frame_config)?;
let path = config
.output_dir
.join(format!("frame_{:06}.png", frame + 1));
img.save(&path)
.map_err(|e| RasterError::ImageEncode(e.to_string()))?;
paths.push(path);
}
Ok(paths)
}
pub fn render_parallel<F, B>(
backend: &B,
config: &NativeRenderConfig,
scene_fn: F,
) -> Result<Vec<PathBuf>, RasterError>
where
B: RasterizerBackend + Send + Sync,
F: Fn(u32) -> Scene + Send + Sync,
{
std::fs::create_dir_all(&config.output_dir)?;
let width = config.width;
let height = config.height;
let fps = config.fps;
let total = config.duration_in_frames;
let dir = &config.output_dir;
let pool = match config.concurrency {
Some(n) => rayon::ThreadPoolBuilder::new()
.num_threads(n)
.build()
.map_err(|e| RasterError::Init(format!("Failed to build thread pool: {e}")))?,
None => rayon::ThreadPoolBuilder::new()
.build()
.map_err(|e| RasterError::Init(format!("Failed to build thread pool: {e}")))?,
};
let rendered: Result<Vec<(u32, RgbaImage)>, RasterError> = pool.install(|| {
(0..total)
.into_par_iter()
.map(|frame| {
let scene = scene_fn(frame);
let frame_cfg = FrameConfig::new(width, height, frame, fps);
let img = backend.render_frame(&scene, &frame_cfg)?;
Ok((frame, img))
})
.collect()
});
let mut pairs = rendered?;
pairs.sort_by_key(|(f, _)| *f);
let mut paths = Vec::with_capacity(total as usize);
for (frame, img) in pairs {
let path = dir.join(format!("frame_{:06}.png", frame + 1));
img.save(&path)
.map_err(|e| RasterError::ImageEncode(e.to_string()))?;
paths.push(path);
}
Ok(paths)
}
pub fn render_to_ffmpeg_pipe<F, B>(
backend: &B,
config: &PipeConfig,
scene_fn: F,
) -> Result<(), RasterError>
where
B: RasterizerBackend + Send + Sync,
F: Fn(u32) -> Scene + Send + Sync,
{
render_to_ffmpeg_pipe_fallible(backend, config, |frame| {
Ok::<Scene, std::convert::Infallible>(scene_fn(frame))
})
}
pub fn render_to_ffmpeg_pipe_fallible<F, B, E>(
backend: &B,
config: &PipeConfig,
scene_fn: F,
) -> Result<(), RasterError>
where
B: RasterizerBackend + Send + Sync,
F: Fn(u32) -> Result<Scene, E> + Send + Sync,
E: std::fmt::Display + Send,
{
let width = config.width;
let height = config.height;
let fps = config.fps;
let total = config.duration_in_frames;
let ffmpeg_args = build_pipe_ffmpeg_args(config);
let mut ffmpeg = Command::new("ffmpeg")
.args(&ffmpeg_args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
RasterError::Init(format!("Failed to spawn FFmpeg: {e}\nIs ffmpeg installed?"))
})?;
let concurrency = config.concurrency.unwrap_or_else(|| {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
});
if concurrency == 0 {
let _ = ffmpeg.kill();
let _ = ffmpeg.wait();
return Err(RasterError::Init(
"Render concurrency must be greater than zero".into(),
));
}
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(concurrency)
.build()
.map_err(|e| RasterError::Init(format!("Rayon pool error: {e}")))?;
let mut stdin = ffmpeg
.stdin
.take()
.ok_or_else(|| RasterError::Init("Failed to open FFmpeg stdin".into()))?;
let render_result = (0..total).step_by(concurrency).try_for_each(|batch_start| {
let batch_end = total.min(batch_start.saturating_add(concurrency as u32));
let rendered: Result<Vec<(u32, Vec<u8>)>, RasterError> = pool.install(|| {
(batch_start..batch_end)
.into_par_iter()
.map(|frame| {
let scene = scene_fn(frame).map_err(|error| RasterError::Frame {
frame,
reason: error.to_string(),
})?;
let frame_cfg = FrameConfig::new(width, height, frame, fps);
let img = backend.render_frame(&scene, &frame_cfg)?;
Ok((frame, img.into_raw()))
})
.collect()
});
let mut frames = rendered?;
frames.sort_by_key(|(frame, _)| *frame);
for (_, rgba) in frames {
stdin
.write_all(&rgba)
.map_err(|e| RasterError::ImageEncode(format!("FFmpeg pipe write error: {e}")))?;
}
Ok::<(), RasterError>(())
});
if render_result.is_ok() {
let _ = stdin.flush();
}
drop(stdin);
if let Err(error) = render_result {
let _ = ffmpeg.kill();
let _ = ffmpeg.wait();
return Err(error);
}
let output = ffmpeg
.wait_with_output()
.map_err(|e| RasterError::ImageEncode(format!("FFmpeg wait error: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RasterError::ImageEncode(format!(
"FFmpeg exited with non-zero status {:?}: {}",
output.status.code(),
stderr.trim()
)));
}
Ok(())
}
pub fn build_pipe_ffmpeg_args(config: &PipeConfig) -> Vec<String> {
let args = vec![
"-y".into(), "-loglevel".into(),
"error".into(),
"-f".into(),
"rawvideo".into(), "-pix_fmt".into(),
"rgba".into(), "-s".into(),
format!("{}x{}", config.width, config.height),
"-r".into(),
format!("{}", config.fps),
"-i".into(),
"pipe:0".into(), "-c:v".into(),
"libx264".into(),
"-pix_fmt".into(),
"yuv420p".into(),
"-crf".into(),
config.crf.to_string(),
"-preset".into(),
config.preset.clone(),
"-movflags".into(),
"+faststart".into(),
config.output.to_string_lossy().to_string(),
];
args
}
pub fn save_frame(img: &RgbaImage, path: &Path) -> Result<(), RasterError> {
img.save(path)
.map_err(|e| RasterError::ImageEncode(e.to_string()))
}
pub fn render_frame_timed(
backend: &dyn RasterizerBackend,
scene: &Scene,
config: &FrameConfig,
) -> Result<(RgbaImage, std::time::Duration), RasterError> {
let start = std::time::Instant::now();
let img = backend.render_frame(scene, config)?;
Ok((img, start.elapsed()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scene::{Color, Scene, SceneNode};
use crate::tiny_skia_backend::TinySkiaBackend;
fn solid_scene(color: Color) -> impl Fn(u32) -> Scene + Send + Sync {
move |_frame| {
let mut s = Scene::new();
s.push(SceneNode::Rect {
x: 0.0,
y: 0.0,
w: 64.0,
h: 64.0,
fill: color,
stroke: None,
stroke_width: 0.0,
corner_radius: 0.0,
});
s
}
}
#[test]
fn test_sequential_renders_correct_count() {
let backend = TinySkiaBackend::headless();
let tmp = std::env::temp_dir().join("dioxuscut_test_seq");
let _ = std::fs::remove_dir_all(&tmp);
let config = NativeRenderConfig::new(64, 64, 30.0, 5, &tmp);
let paths = render_all_frames(&backend, &config, |frame| {
let mut s = Scene::new();
s.push(SceneNode::Rect {
x: 0.0,
y: 0.0,
w: 64.0,
h: 64.0,
fill: Color::rgb(frame as u8 * 40, 0, 0),
stroke: None,
stroke_width: 0.0,
corner_radius: 0.0,
});
s
})
.expect("sequential render failed");
assert_eq!(paths.len(), 5, "Should have 5 frame files");
for p in &paths {
assert!(p.exists(), "PNG file should exist: {p:?}");
}
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_parallel_renders_same_count_as_sequential() {
let backend = TinySkiaBackend::headless();
let tmp = std::env::temp_dir().join("dioxuscut_test_par");
let _ = std::fs::remove_dir_all(&tmp);
let config = NativeRenderConfig::new(64, 64, 30.0, 10, &tmp).with_concurrency(4);
let paths = render_parallel(&backend, &config, solid_scene(Color::rgb(0, 0, 255)))
.expect("parallel render failed");
assert_eq!(paths.len(), 10, "Should have 10 frame files");
for (i, p) in paths.iter().enumerate() {
let name = p.file_name().unwrap().to_string_lossy().to_string();
assert_eq!(name, format!("frame_{:06}.png", i + 1));
}
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_parallel_pixel_values_correct() {
let backend = TinySkiaBackend::headless();
let tmp = std::env::temp_dir().join("dioxuscut_test_par_px");
let _ = std::fs::remove_dir_all(&tmp);
let config = NativeRenderConfig::new(64, 64, 30.0, 4, &tmp);
let paths = render_parallel(&backend, &config, |frame| {
let mut s = Scene::new();
s.push(SceneNode::Rect {
x: 0.0,
y: 0.0,
w: 64.0,
h: 64.0,
fill: Color::rgb(frame as u8 * 60, 0, 0),
stroke: None,
stroke_width: 0.0,
corner_radius: 0.0,
});
s
})
.expect("parallel pixel render failed");
for (i, path) in paths.iter().enumerate() {
let img = image::open(path).expect("open frame").into_rgba8();
let px = img.get_pixel(32, 32);
let expected_r = (i as u8) * 60;
assert_eq!(px[0], expected_r, "Frame {i}: red channel mismatch");
}
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_pipe_config_ffmpeg_args() {
let config = PipeConfig::new(1920, 1080, 30.0, 10, "/tmp/out.mp4");
let args = build_pipe_ffmpeg_args(&config);
assert!(
args.contains(&"rawvideo".to_string()),
"args should contain rawvideo"
);
assert!(
args.contains(&"1920x1080".to_string()),
"args should contain resolution"
);
assert!(
args.contains(&"pipe:0".to_string()),
"args should read from stdin"
);
assert!(
args.contains(&"/tmp/out.mp4".to_string()),
"args should contain output path"
);
}
#[test]
fn test_frame_timed() {
let backend = TinySkiaBackend::headless();
let mut scene = Scene::new();
scene.push(SceneNode::Circle {
cx: 32.0,
cy: 32.0,
r: 20.0,
fill: Color::rgb(255, 128, 0),
stroke: None,
stroke_width: 0.0,
});
let config = FrameConfig::new(64, 64, 0, 30.0);
let (img, elapsed) =
render_frame_timed(&backend, &scene, &config).expect("timed render failed");
assert_eq!(img.width(), 64);
println!("Single 64x64 frame: {:?}", elapsed);
}
}