Skip to main content

dioxuscut_rasterizer/
render.rs

1//! Frame rendering coordinator — sequential, parallel, and streaming pipeline modes.
2//!
3//! # Render Modes
4//!
5//! | Mode                     | I/O               | Parallelism | Best for                         |
6//! |--------------------------|-------------------|-------------|----------------------------------|
7//! | [`render_all_frames`]    | PNG files on disk | Sequential  | Debugging, inspection            |
8//! | [`render_parallel`]      | PNG files on disk | Rayon (N cores) | Large renders with disk I/O  |
9//! | [`render_to_ffmpeg_pipe`]| FFmpeg stdin      | Rayon + pipe| **Fastest** — zero PNG overhead  |
10//!
11//! ## Pipeline comparison
12//!
13//! ```text
14//! Sequential PNG:   [frame 0] → PNG → [frame 1] → PNG → … → FFmpeg
15//! Parallel PNG:     [frame 0]
16//!                   [frame 1]  (all at once, Rayon)
17//!                   [frame 2] → disk → FFmpeg
18//!
19//! Pipe (fastest):   bounded Rayon batch → ordered RGBA frames → FFmpeg → MP4
20//!                   Zero disk I/O, zero PNG compression overhead
21//! ```
22
23use crate::backend::{FrameConfig, RasterError, RasterizerBackend};
24use crate::scene::{AudioTrack, Scene};
25use crate::video_cache::canonical_local_path;
26use image::RgbaImage;
27use rayon::prelude::*;
28use std::fmt;
29use std::io::{Read, Write};
30use std::path::{Path, PathBuf};
31use std::process::{Command, Stdio};
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::sync::{Arc, OnceLock};
34use std::time::{Duration, Instant};
35
36// ── Types ────────────────────────────────────────────────────────────────────
37
38/// Configuration for a native render job.
39#[derive(Debug, Clone)]
40pub struct NativeRenderConfig {
41    pub width: u32,
42    pub height: u32,
43    pub fps: f64,
44    pub duration_in_frames: u32,
45    pub output_dir: PathBuf,
46    /// Number of Rayon threads to use. `None` = auto (# logical CPUs).
47    pub concurrency: Option<usize>,
48}
49
50impl NativeRenderConfig {
51    pub fn new(
52        width: u32,
53        height: u32,
54        fps: f64,
55        duration_in_frames: u32,
56        output_dir: impl Into<PathBuf>,
57    ) -> Self {
58        Self {
59            width,
60            height,
61            fps,
62            duration_in_frames,
63            output_dir: output_dir.into(),
64            concurrency: None,
65        }
66    }
67
68    pub fn with_concurrency(mut self, n: usize) -> Self {
69        self.concurrency = Some(n);
70        self
71    }
72}
73
74/// Video codec used by the FFmpeg pipe encoder.
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
76pub enum VideoCodec {
77    #[default]
78    H264,
79    H265,
80    Vp9,
81    Av1,
82    ProRes,
83    Gif,
84}
85
86#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
87pub enum StillImageFormat {
88    #[default]
89    Png,
90    Jpeg,
91    WebP,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub struct RenderProgress {
96    pub completed_frames: u32,
97    pub total_frames: u32,
98    pub frame: u32,
99}
100
101#[derive(Clone, Default)]
102pub struct RenderCancellationToken(Arc<AtomicBool>);
103
104impl RenderCancellationToken {
105    pub fn cancel(&self) {
106        self.0.store(true, Ordering::Release);
107    }
108
109    pub fn is_cancelled(&self) -> bool {
110        self.0.load(Ordering::Acquire)
111    }
112}
113
114#[derive(Clone, Default)]
115pub struct RenderControl {
116    cancellation: RenderCancellationToken,
117    timeout: Option<Duration>,
118    progress: Option<Arc<dyn Fn(RenderProgress) + Send + Sync>>,
119}
120
121impl fmt::Debug for RenderControl {
122    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123        formatter
124            .debug_struct("RenderControl")
125            .field("cancelled", &self.cancellation.is_cancelled())
126            .field("timeout", &self.timeout)
127            .field("has_progress_callback", &self.progress.is_some())
128            .finish()
129    }
130}
131
132impl RenderControl {
133    pub fn new() -> Self {
134        Self::default()
135    }
136
137    pub fn cancellation_token(&self) -> RenderCancellationToken {
138        self.cancellation.clone()
139    }
140
141    pub fn with_timeout(mut self, timeout: Duration) -> Self {
142        self.timeout = Some(timeout);
143        self
144    }
145
146    pub fn with_progress(
147        mut self,
148        callback: impl Fn(RenderProgress) + Send + Sync + 'static,
149    ) -> Self {
150        self.progress = Some(Arc::new(callback));
151        self
152    }
153
154    fn check(&self, started: Instant) -> Result<(), RasterError> {
155        if self.cancellation.is_cancelled() {
156            return Err(RasterError::Cancelled);
157        }
158        if self
159            .timeout
160            .is_some_and(|timeout| started.elapsed() >= timeout)
161        {
162            return Err(RasterError::Timeout);
163        }
164        Ok(())
165    }
166}
167
168/// Piped encoding configuration (no intermediate PNG files).
169#[derive(Debug, Clone)]
170pub struct PipeConfig {
171    pub width: u32,
172    pub height: u32,
173    pub fps: f64,
174    pub duration_in_frames: u32,
175    /// First composition frame included in the output.
176    pub start_frame: u32,
177    /// Output media file path.
178    pub output: PathBuf,
179    /// Number of parallel render workers. `None` = auto.
180    pub concurrency: Option<usize>,
181    /// FFmpeg CRF quality (0–51, lower = better).
182    pub crf: u32,
183    /// FFmpeg preset: "ultrafast", "fast", "medium", etc.
184    pub preset: String,
185    pub codec: VideoCodec,
186    /// Audio tracks mixed and trimmed to the rendered video duration.
187    pub audio_tracks: Vec<AudioTrack>,
188    pub control: RenderControl,
189}
190
191impl PipeConfig {
192    pub fn new(
193        width: u32,
194        height: u32,
195        fps: f64,
196        duration_in_frames: u32,
197        output: impl Into<PathBuf>,
198    ) -> Self {
199        Self {
200            width,
201            height,
202            fps,
203            duration_in_frames,
204            start_frame: 0,
205            output: output.into(),
206            concurrency: None,
207            crf: 18,
208            preset: "fast".to_string(),
209            codec: VideoCodec::H264,
210            audio_tracks: Vec::new(),
211            control: RenderControl::default(),
212        }
213    }
214
215    pub fn with_concurrency(mut self, n: usize) -> Self {
216        self.concurrency = Some(n);
217        self
218    }
219
220    pub fn with_quality(mut self, crf: u32, preset: impl Into<String>) -> Self {
221        self.crf = crf;
222        self.preset = preset.into();
223        self
224    }
225
226    pub fn with_audio_tracks(mut self, tracks: impl IntoIterator<Item = AudioTrack>) -> Self {
227        self.audio_tracks = tracks.into_iter().collect();
228        self
229    }
230
231    pub fn with_codec(mut self, codec: VideoCodec) -> Self {
232        self.codec = codec;
233        self
234    }
235
236    pub fn with_frame_start(mut self, start_frame: u32) -> Self {
237        self.start_frame = start_frame;
238        self
239    }
240
241    pub fn with_control(mut self, control: RenderControl) -> Self {
242        self.control = control;
243        self
244    }
245}
246
247/// Render one composition frame directly to PNG, JPEG, or WebP.
248#[allow(clippy::too_many_arguments)]
249pub fn render_still_fallible<F, B, E>(
250    backend: &B,
251    width: u32,
252    height: u32,
253    fps: f64,
254    frame: u32,
255    output: &Path,
256    format: StillImageFormat,
257    control: &RenderControl,
258    scene_fn: F,
259) -> Result<(), RasterError>
260where
261    B: RasterizerBackend + Send + Sync,
262    F: FnOnce(u32) -> Result<Scene, E>,
263    E: std::fmt::Display,
264{
265    let started = Instant::now();
266    control.check(started)?;
267    let scene = scene_fn(frame).map_err(|error| RasterError::Frame {
268        frame,
269        reason: error.to_string(),
270    })?;
271    let image = backend.render_frame(&scene, &FrameConfig::new(width, height, frame, fps))?;
272    control.check(started)?;
273    match format {
274        StillImageFormat::Png => image
275            .save_with_format(output, image::ImageFormat::Png)
276            .map_err(|error| RasterError::ImageEncode(error.to_string()))?,
277        StillImageFormat::Jpeg => image::DynamicImage::ImageRgba8(image)
278            .to_rgb8()
279            .save_with_format(output, image::ImageFormat::Jpeg)
280            .map_err(|error| RasterError::ImageEncode(error.to_string()))?,
281        StillImageFormat::WebP => image
282            .save_with_format(output, image::ImageFormat::WebP)
283            .map_err(|error| RasterError::ImageEncode(error.to_string()))?,
284    }
285    if let Some(callback) = &control.progress {
286        callback(RenderProgress {
287            completed_frames: 1,
288            total_frames: 1,
289            frame,
290        });
291    }
292    Ok(())
293}
294
295// ── Mode 1: Sequential PNG ───────────────────────────────────────────────────
296
297/// Render all frames sequentially to PNG files.
298///
299/// Each frame is rendered in order and saved as `frame_000001.png`, etc.
300/// Simple and debuggable, but slow for large frame counts.
301pub fn render_all_frames<F>(
302    backend: &dyn RasterizerBackend,
303    config: &NativeRenderConfig,
304    mut scene_fn: F,
305) -> Result<Vec<PathBuf>, RasterError>
306where
307    F: FnMut(u32) -> Scene,
308{
309    std::fs::create_dir_all(&config.output_dir)?;
310
311    let mut paths = Vec::with_capacity(config.duration_in_frames as usize);
312
313    for frame in 0..config.duration_in_frames {
314        let scene = scene_fn(frame);
315        let frame_config = FrameConfig::new(config.width, config.height, frame, config.fps);
316        let img = backend.render_frame(&scene, &frame_config)?;
317
318        let path = config
319            .output_dir
320            .join(format!("frame_{:06}.png", frame + 1));
321        img.save(&path)
322            .map_err(|e| RasterError::ImageEncode(e.to_string()))?;
323        paths.push(path);
324    }
325
326    Ok(paths)
327}
328
329// ── Mode 2: Parallel PNG ─────────────────────────────────────────────────────
330
331/// Render all frames in parallel using Rayon, then write PNGs.
332///
333/// Frames are rendered concurrently across all available CPU cores.
334/// Requires `backend` to implement `Send + Sync`.
335///
336/// # Performance
337/// On a machine with N cores, this is roughly N× faster than sequential
338/// for the rasterization step. PNG file I/O is still sequential to preserve order.
339pub fn render_parallel<F, B>(
340    backend: &B,
341    config: &NativeRenderConfig,
342    scene_fn: F,
343) -> Result<Vec<PathBuf>, RasterError>
344where
345    B: RasterizerBackend + Send + Sync,
346    F: Fn(u32) -> Scene + Send + Sync,
347{
348    std::fs::create_dir_all(&config.output_dir)?;
349
350    let width = config.width;
351    let height = config.height;
352    let fps = config.fps;
353    let total = config.duration_in_frames;
354    let dir = &config.output_dir;
355
356    // Configure Rayon thread pool if explicit concurrency was requested
357    let pool = match config.concurrency {
358        Some(n) => rayon::ThreadPoolBuilder::new()
359            .num_threads(n)
360            .build()
361            .map_err(|e| RasterError::Init(format!("Failed to build thread pool: {e}")))?,
362        None => rayon::ThreadPoolBuilder::new()
363            .build()
364            .map_err(|e| RasterError::Init(format!("Failed to build thread pool: {e}")))?,
365    };
366
367    // Render all frames in parallel → collect (frame_index, img) pairs
368    let rendered: Result<Vec<(u32, RgbaImage)>, RasterError> = pool.install(|| {
369        (0..total)
370            .into_par_iter()
371            .map(|frame| {
372                let scene = scene_fn(frame);
373                let frame_cfg = FrameConfig::new(width, height, frame, fps);
374                let img = backend.render_frame(&scene, &frame_cfg)?;
375                Ok((frame, img))
376            })
377            .collect()
378    });
379
380    let mut pairs = rendered?;
381    // Sort by frame index (parallel iteration doesn't guarantee order)
382    pairs.sort_by_key(|(f, _)| *f);
383
384    // Write PNGs in order
385    let mut paths = Vec::with_capacity(total as usize);
386    for (frame, img) in pairs {
387        let path = dir.join(format!("frame_{:06}.png", frame + 1));
388        img.save(&path)
389            .map_err(|e| RasterError::ImageEncode(e.to_string()))?;
390        paths.push(path);
391    }
392
393    Ok(paths)
394}
395
396// ── Mode 3: FFmpeg stdin pipe (fastest) ──────────────────────────────────────
397
398/// Render frames in bounded parallel batches and stream raw RGBA to FFmpeg stdin.
399///
400/// **This is the fastest rendering mode.** It eliminates:
401/// - PNG compression overhead
402/// - Disk write latency for intermediate frames
403/// - A second disk read by FFmpeg
404///
405/// # Pipeline
406/// ```text
407/// bounded Rayon batch → ordered RGBA frames → FFmpeg stdin → MP4
408/// ```
409///
410/// # FFmpeg invocation
411/// ```text
412/// ffmpeg -f rawvideo -pix_fmt rgba -s WxH -r FPS -i pipe:0
413///        [-i audio ... -filter_complex mix] -c:v libx264 -c:a aac
414///        -pix_fmt yuv420p -crf N -preset P
415///        -movflags +faststart output.mp4
416/// ```
417pub fn render_to_ffmpeg_pipe<F, B>(
418    backend: &B,
419    config: &PipeConfig,
420    scene_fn: F,
421) -> Result<(), RasterError>
422where
423    B: RasterizerBackend + Send + Sync,
424    F: Fn(u32) -> Scene + Send + Sync,
425{
426    render_to_ffmpeg_pipe_fallible(backend, config, |frame| {
427        Ok::<Scene, std::convert::Infallible>(scene_fn(frame))
428    })
429}
430
431/// Fallible variant of [`render_to_ffmpeg_pipe`].
432///
433/// Scene generation errors are annotated with the frame number and propagated
434/// before rasterization. This is intended for script-backed compositions and
435/// other dynamic scene sources that can fail while evaluating a frame.
436pub fn render_to_ffmpeg_pipe_fallible<F, B, E>(
437    backend: &B,
438    config: &PipeConfig,
439    scene_fn: F,
440) -> Result<(), RasterError>
441where
442    B: RasterizerBackend + Send + Sync,
443    F: Fn(u32) -> Result<Scene, E> + Send + Sync,
444    E: std::fmt::Display + Send,
445{
446    let width = config.width;
447    let height = config.height;
448    let fps = config.fps;
449    let total = config.duration_in_frames;
450    let started = Instant::now();
451
452    validate_pipe_config(config)?;
453    config.control.check(started)?;
454
455    // ── 1. Spawn FFmpeg ──────────────────────────────────────────────────────
456    let ffmpeg_args = build_pipe_ffmpeg_args(config);
457    let mut ffmpeg = Command::new("ffmpeg")
458        .args(&ffmpeg_args)
459        .stdin(Stdio::piped())
460        .stdout(Stdio::null())
461        .stderr(Stdio::piped())
462        .spawn()
463        .map_err(|e| {
464            RasterError::Init(format!("Failed to spawn FFmpeg: {e}\nIs ffmpeg installed?"))
465        })?;
466
467    // ── 2. Render frames in parallel ─────────────────────────────────────────
468    let concurrency = config.concurrency.unwrap_or_else(|| {
469        std::thread::available_parallelism()
470            .map(|n| n.get())
471            .unwrap_or(4)
472    });
473    if concurrency == 0 {
474        let _ = ffmpeg.kill();
475        let _ = ffmpeg.wait();
476        return Err(RasterError::Init(
477            "Render concurrency must be greater than zero".into(),
478        ));
479    }
480
481    let pool = rayon::ThreadPoolBuilder::new()
482        .num_threads(concurrency)
483        .build()
484        .map_err(|e| {
485            let _ = ffmpeg.kill();
486            let _ = ffmpeg.wait();
487            RasterError::Init(format!("Rayon pool error: {e}"))
488        })?;
489
490    // ── 3. Render and stream bounded batches in frame order ───────────────────
491    // At most `concurrency` raw frames are retained at once. This keeps memory
492    // proportional to the worker count instead of the video duration.
493    let mut stdin = ffmpeg
494        .stdin
495        .take()
496        .ok_or_else(|| RasterError::Init("Failed to open FFmpeg stdin".into()))?;
497
498    let render_result = (0..total).step_by(concurrency).try_for_each(|batch_start| {
499        config.control.check(started)?;
500        let batch_end = total.min(batch_start.saturating_add(concurrency as u32));
501        let rendered: Result<Vec<(u32, u32, Vec<u8>)>, RasterError> = pool.install(|| {
502            (batch_start..batch_end)
503                .into_par_iter()
504                .map(|frame| {
505                    config.control.check(started)?;
506                    let composition_frame =
507                        config.start_frame.checked_add(frame).ok_or_else(|| {
508                            RasterError::Scene("render frame range overflows u32".into())
509                        })?;
510                    let scene =
511                        scene_fn(composition_frame).map_err(|error| RasterError::Frame {
512                            frame: composition_frame,
513                            reason: error.to_string(),
514                        })?;
515                    let frame_cfg = FrameConfig::new(width, height, composition_frame, fps);
516                    let img = backend.render_frame(&scene, &frame_cfg)?;
517                    config.control.check(started)?;
518                    Ok((frame, composition_frame, img.into_raw()))
519                })
520                .collect()
521        });
522
523        let mut frames = rendered?;
524        frames.sort_by_key(|(frame, _, _)| *frame);
525        for (frame, composition_frame, rgba) in frames {
526            config.control.check(started)?;
527            stdin
528                .write_all(&rgba)
529                .map_err(|e| RasterError::ImageEncode(format!("FFmpeg pipe write error: {e}")))?;
530            if let Some(callback) = &config.control.progress {
531                callback(RenderProgress {
532                    completed_frames: frame + 1,
533                    total_frames: total,
534                    frame: composition_frame,
535                });
536            }
537        }
538        Ok::<(), RasterError>(())
539    });
540
541    if render_result.is_ok() {
542        let _ = stdin.flush();
543    }
544    drop(stdin); // EOF for FFmpeg
545
546    if let Err(error) = render_result {
547        let _ = ffmpeg.kill();
548        let _ = ffmpeg.wait();
549        return Err(error);
550    }
551
552    // ── 4. Wait for FFmpeg to finish ─────────────────────────────────────────
553    let output = wait_for_ffmpeg(ffmpeg, &config.control, started)?;
554
555    if !output.status.success() {
556        let stderr = String::from_utf8_lossy(&output.stderr);
557        return Err(RasterError::ImageEncode(format!(
558            "FFmpeg exited with non-zero status {:?}: {}",
559            output.status.code(),
560            stderr.trim()
561        )));
562    }
563
564    Ok(())
565}
566
567fn validate_pipe_config(config: &PipeConfig) -> Result<(), RasterError> {
568    if config.width == 0 || config.height == 0 {
569        return Err(RasterError::Init(
570            "render width and height must be positive".into(),
571        ));
572    }
573    if !config.fps.is_finite() || config.fps <= 0.0 {
574        return Err(RasterError::Init(
575            "render FPS must be finite and positive".into(),
576        ));
577    }
578    if config.duration_in_frames == 0 {
579        return Err(RasterError::Init(
580            "render duration must contain at least one frame".into(),
581        ));
582    }
583    config
584        .start_frame
585        .checked_add(config.duration_in_frames - 1)
586        .ok_or_else(|| RasterError::Init("render frame range overflows u32".into()))?;
587    if config.codec != VideoCodec::Gif
588        && (!config.width.is_multiple_of(2) || !config.height.is_multiple_of(2))
589    {
590        return Err(RasterError::Init(
591            "video render width and height must be even".into(),
592        ));
593    }
594    let max_crf = match config.codec {
595        VideoCodec::H264 | VideoCodec::H265 => Some(51),
596        VideoCodec::Vp9 | VideoCodec::Av1 => Some(63),
597        VideoCodec::ProRes | VideoCodec::Gif => None,
598    };
599    if max_crf.is_some_and(|maximum| config.crf > maximum) {
600        return Err(RasterError::Init(format!(
601            "CRF {} exceeds the {:?} maximum of {}",
602            config.crf,
603            config.codec,
604            max_crf.expect("checked above")
605        )));
606    }
607    if config.codec == VideoCodec::Gif && !config.audio_tracks.is_empty() {
608        return Err(RasterError::Scene(
609            "GIF output does not support audio tracks".into(),
610        ));
611    }
612    validate_audio_tracks(&config.audio_tracks)
613}
614
615fn wait_for_ffmpeg(
616    mut child: std::process::Child,
617    control: &RenderControl,
618    started: Instant,
619) -> Result<std::process::Output, RasterError> {
620    loop {
621        if let Err(error) = control.check(started) {
622            let _ = child.kill();
623            let _ = child.wait();
624            return Err(error);
625        }
626        match child.try_wait() {
627            Ok(Some(status)) => {
628                let mut stderr = Vec::new();
629                if let Some(mut pipe) = child.stderr.take() {
630                    pipe.read_to_end(&mut stderr).map_err(|error| {
631                        RasterError::ImageEncode(format!("FFmpeg stderr read error: {error}"))
632                    })?;
633                }
634                return Ok(std::process::Output {
635                    status,
636                    stdout: Vec::new(),
637                    stderr,
638                });
639            }
640            Ok(None) => std::thread::sleep(Duration::from_millis(10)),
641            Err(error) => {
642                let _ = child.kill();
643                let _ = child.wait();
644                return Err(RasterError::ImageEncode(format!(
645                    "FFmpeg wait error: {error}"
646                )));
647            }
648        }
649    }
650}
651
652/// Build FFmpeg arguments for rawvideo stdin pipe and the selected codec.
653pub fn build_pipe_ffmpeg_args(config: &PipeConfig) -> Vec<String> {
654    let mut args = vec![
655        "-y".into(), // overwrite output
656        "-loglevel".into(),
657        "error".into(),
658        "-f".into(),
659        "rawvideo".into(), // input format
660        "-pix_fmt".into(),
661        "rgba".into(), // pixel format
662        "-s".into(),
663        format!("{}x{}", config.width, config.height),
664        "-r".into(),
665        format!("{}", config.fps),
666        "-i".into(),
667        "pipe:0".into(), // read from stdin
668    ];
669
670    let audio_tracks = active_audio_tracks(config);
671    for track in &audio_tracks {
672        if track.looped {
673            args.extend(["-stream_loop".into(), "-1".into()]);
674        }
675        args.extend(["-i".into(), track.src.clone()]);
676    }
677
678    if config.codec == VideoCodec::Gif {
679        args.extend([
680            "-filter_complex".into(),
681            "[0:v]split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse[gif]".into(),
682            "-map".into(),
683            "[gif]".into(),
684            "-an".into(),
685        ]);
686    } else {
687        args.extend(["-map".into(), "0:v:0".into()]);
688        if audio_tracks.is_empty() {
689            args.push("-an".into());
690        } else {
691            let audio_codec = match config.codec {
692                VideoCodec::Vp9 | VideoCodec::Av1 => "libopus",
693                VideoCodec::ProRes => "pcm_s16le",
694                VideoCodec::H264 | VideoCodec::H265 => "aac",
695                VideoCodec::Gif => unreachable!(),
696            };
697            args.extend([
698                "-filter_complex".into(),
699                build_audio_filter(config, &audio_tracks),
700                "-map".into(),
701                "[aout]".into(),
702                "-c:a".into(),
703                audio_codec.into(),
704            ]);
705            if audio_codec != "pcm_s16le" {
706                args.extend(["-b:a".into(), "192k".into()]);
707            }
708        }
709    }
710
711    match config.codec {
712        VideoCodec::H264 => args.extend([
713            "-c:v".into(),
714            "libx264".into(),
715            "-pix_fmt".into(),
716            "yuv420p".into(),
717            "-crf".into(),
718            config.crf.to_string(),
719            "-preset".into(),
720            config.preset.clone(),
721            "-movflags".into(),
722            "+faststart".into(),
723        ]),
724        VideoCodec::H265 => args.extend([
725            "-c:v".into(),
726            "libx265".into(),
727            "-tag:v".into(),
728            "hvc1".into(),
729            "-pix_fmt".into(),
730            "yuv420p".into(),
731            "-crf".into(),
732            config.crf.to_string(),
733            "-preset".into(),
734            config.preset.clone(),
735            "-movflags".into(),
736            "+faststart".into(),
737        ]),
738        VideoCodec::Vp9 => args.extend([
739            "-c:v".into(),
740            "libvpx-vp9".into(),
741            "-pix_fmt".into(),
742            "yuv420p".into(),
743            "-crf".into(),
744            config.crf.min(63).to_string(),
745            "-b:v".into(),
746            "0".into(),
747        ]),
748        VideoCodec::Av1 => {
749            if ffmpeg_has_encoder("libsvtav1") {
750                args.extend([
751                    "-c:v".into(),
752                    "libsvtav1".into(),
753                    "-pix_fmt".into(),
754                    "yuv420p".into(),
755                    "-crf".into(),
756                    config.crf.min(63).to_string(),
757                    "-preset".into(),
758                    "8".into(),
759                ]);
760            } else {
761                args.extend([
762                    "-c:v".into(),
763                    "libaom-av1".into(),
764                    "-pix_fmt".into(),
765                    "yuv420p".into(),
766                    "-crf".into(),
767                    config.crf.min(63).to_string(),
768                    "-b:v".into(),
769                    "0".into(),
770                    "-cpu-used".into(),
771                    "6".into(),
772                ]);
773            }
774        }
775        VideoCodec::ProRes => args.extend([
776            "-c:v".into(),
777            "prores_ks".into(),
778            "-profile:v".into(),
779            "3".into(),
780            "-pix_fmt".into(),
781            "yuv422p10le".into(),
782        ]),
783        VideoCodec::Gif => {}
784    }
785    args.extend([
786        "-t".into(),
787        format!("{:.9}", config.duration_in_frames as f64 / config.fps),
788        config.output.to_string_lossy().to_string(),
789    ]);
790    args
791}
792
793fn ffmpeg_has_encoder(name: &str) -> bool {
794    static ENCODERS: OnceLock<String> = OnceLock::new();
795    ENCODERS
796        .get_or_init(|| {
797            Command::new("ffmpeg")
798                .args(["-hide_banner", "-encoders"])
799                .output()
800                .map(|output| String::from_utf8_lossy(&output.stdout).into_owned())
801                .unwrap_or_default()
802        })
803        .split_whitespace()
804        .any(|encoder| encoder == name)
805}
806
807fn validate_audio_tracks(tracks: &[AudioTrack]) -> Result<(), RasterError> {
808    for track in tracks {
809        canonical_local_path(&track.src)?;
810        if !track.start_from.is_finite() || track.start_from < 0.0 {
811            return Err(invalid_audio(
812                track,
813                "start_from must be finite and non-negative",
814            ));
815        }
816        if !track.timeline_start.is_finite() || track.timeline_start < 0.0 {
817            return Err(invalid_audio(
818                track,
819                "timeline_start must be finite and non-negative",
820            ));
821        }
822        if track
823            .duration
824            .is_some_and(|duration| !duration.is_finite() || duration <= 0.0)
825        {
826            return Err(invalid_audio(track, "duration must be finite and positive"));
827        }
828        if !track.volume.is_finite() || !(0.0..=1.0).contains(&track.volume) {
829            return Err(invalid_audio(track, "volume must be between 0.0 and 1.0"));
830        }
831        if !track.playback_rate.is_finite() || !(0.5..=2.0).contains(&track.playback_rate) {
832            return Err(invalid_audio(
833                track,
834                "playback_rate must be between 0.5 and 2.0",
835            ));
836        }
837    }
838    Ok(())
839}
840
841fn invalid_audio(track: &AudioTrack, reason: &str) -> RasterError {
842    RasterError::MediaAsset {
843        path: track.src.clone(),
844        reason: reason.into(),
845    }
846}
847
848fn active_audio_tracks(config: &PipeConfig) -> Vec<&AudioTrack> {
849    let range_start = config.start_frame as f64 / config.fps;
850    let range_end = range_start + config.duration_in_frames as f64 / config.fps;
851    config
852        .audio_tracks
853        .iter()
854        .filter(|track| {
855            let track_end = track
856                .duration
857                .map(|duration| track.timeline_start + duration)
858                .unwrap_or(f64::INFINITY);
859            track.timeline_start < range_end && track_end > range_start
860        })
861        .collect()
862}
863
864fn build_audio_filter(config: &PipeConfig, tracks: &[&AudioTrack]) -> String {
865    let mut filters = Vec::with_capacity(tracks.len() + 1);
866    let range_start = config.start_frame as f64 / config.fps;
867    for (index, track) in tracks.iter().enumerate() {
868        let skipped_timeline = (range_start - track.timeline_start).max(0.0);
869        let source_start = track.start_from + skipped_timeline * track.playback_rate;
870        let mut chain = format!(
871            "[{}:a:0]atrim=start={:.9},asetpts=PTS-STARTPTS,atempo={:.9},volume={:.9}",
872            index + 1,
873            source_start,
874            track.playback_rate,
875            track.volume
876        );
877        if let Some(duration) = track.duration {
878            let remaining = (duration - skipped_timeline).max(0.0);
879            chain.push_str(&format!(",atrim=duration={remaining:.9}"));
880        }
881        let relative_start = (track.timeline_start - range_start).max(0.0);
882        if relative_start > 0.0 {
883            let delay_ms = (relative_start * 1000.0).round() as u64;
884            chain.push_str(&format!(",adelay={delay_ms}:all=1"));
885        }
886        chain.push_str(&format!("[a{index}]"));
887        filters.push(chain);
888    }
889
890    let labels = (0..tracks.len())
891        .map(|index| format!("[a{index}]"))
892        .collect::<String>();
893    let output_duration = config.duration_in_frames as f64 / config.fps;
894    if tracks.len() == 1 {
895        filters.push(format!(
896            "{labels}apad,atrim=duration={output_duration:.9},asetpts=N/SR/TB[aout]"
897        ));
898    } else {
899        filters.push(format!(
900            "{labels}amix=inputs={}:normalize=0:duration=longest,apad,atrim=duration={output_duration:.9},asetpts=N/SR/TB[aout]",
901            tracks.len()
902        ));
903    }
904    filters.join(";")
905}
906
907/// Save a single `RgbaImage` frame to disk.
908pub fn save_frame(img: &RgbaImage, path: &Path) -> Result<(), RasterError> {
909    img.save(path)
910        .map_err(|e| RasterError::ImageEncode(e.to_string()))
911}
912
913// ── Benchmark helper ─────────────────────────────────────────────────────────
914
915/// Render a single frame and return elapsed time (for benchmarking).
916pub fn render_frame_timed(
917    backend: &dyn RasterizerBackend,
918    scene: &Scene,
919    config: &FrameConfig,
920) -> Result<(RgbaImage, std::time::Duration), RasterError> {
921    let start = std::time::Instant::now();
922    let img = backend.render_frame(scene, config)?;
923    Ok((img, start.elapsed()))
924}
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929    use crate::scene::{Color, Scene, SceneNode};
930    use crate::tiny_skia_backend::TinySkiaBackend;
931    use std::sync::Mutex;
932    use std::time::{SystemTime, UNIX_EPOCH};
933
934    fn unique_temp_dir(label: &str) -> PathBuf {
935        let nonce = SystemTime::now()
936            .duration_since(UNIX_EPOCH)
937            .expect("system clock before Unix epoch")
938            .as_nanos();
939        std::env::temp_dir().join(format!(
940            "dioxuscut_{label}_{}_{}",
941            std::process::id(),
942            nonce
943        ))
944    }
945
946    fn ffmpeg_available() -> bool {
947        Command::new("ffmpeg")
948            .arg("-version")
949            .stdout(Stdio::null())
950            .stderr(Stdio::null())
951            .status()
952            .is_ok_and(|status| status.success())
953    }
954
955    fn solid_scene(color: Color) -> impl Fn(u32) -> Scene + Send + Sync {
956        move |_frame| {
957            let mut s = Scene::new();
958            s.push(SceneNode::Rect {
959                x: 0.0,
960                y: 0.0,
961                w: 64.0,
962                h: 64.0,
963                fill: color,
964                stroke: None,
965                stroke_width: 0.0,
966                corner_radius: 0.0,
967            });
968            s
969        }
970    }
971
972    #[test]
973    fn test_sequential_renders_correct_count() {
974        let backend = TinySkiaBackend::headless();
975        let tmp = std::env::temp_dir().join("dioxuscut_test_seq");
976        let _ = std::fs::remove_dir_all(&tmp);
977
978        let config = NativeRenderConfig::new(64, 64, 30.0, 5, &tmp);
979        let paths = render_all_frames(&backend, &config, |frame| {
980            let mut s = Scene::new();
981            s.push(SceneNode::Rect {
982                x: 0.0,
983                y: 0.0,
984                w: 64.0,
985                h: 64.0,
986                fill: Color::rgb(frame as u8 * 40, 0, 0),
987                stroke: None,
988                stroke_width: 0.0,
989                corner_radius: 0.0,
990            });
991            s
992        })
993        .expect("sequential render failed");
994
995        assert_eq!(paths.len(), 5, "Should have 5 frame files");
996        for p in &paths {
997            assert!(p.exists(), "PNG file should exist: {p:?}");
998        }
999        let _ = std::fs::remove_dir_all(&tmp);
1000    }
1001
1002    #[test]
1003    fn test_parallel_renders_same_count_as_sequential() {
1004        let backend = TinySkiaBackend::headless();
1005        let tmp = std::env::temp_dir().join("dioxuscut_test_par");
1006        let _ = std::fs::remove_dir_all(&tmp);
1007
1008        let config = NativeRenderConfig::new(64, 64, 30.0, 10, &tmp).with_concurrency(4);
1009
1010        let paths = render_parallel(&backend, &config, solid_scene(Color::rgb(0, 0, 255)))
1011            .expect("parallel render failed");
1012
1013        assert_eq!(paths.len(), 10, "Should have 10 frame files");
1014        // Verify they are in order
1015        for (i, p) in paths.iter().enumerate() {
1016            let name = p.file_name().unwrap().to_string_lossy().to_string();
1017            assert_eq!(name, format!("frame_{:06}.png", i + 1));
1018        }
1019        let _ = std::fs::remove_dir_all(&tmp);
1020    }
1021
1022    #[test]
1023    fn test_parallel_pixel_values_correct() {
1024        let backend = TinySkiaBackend::headless();
1025        let tmp = std::env::temp_dir().join("dioxuscut_test_par_px");
1026        let _ = std::fs::remove_dir_all(&tmp);
1027
1028        let config = NativeRenderConfig::new(64, 64, 30.0, 4, &tmp);
1029        let paths = render_parallel(&backend, &config, |frame| {
1030            let mut s = Scene::new();
1031            // Different colour per frame so we can verify correctness
1032            s.push(SceneNode::Rect {
1033                x: 0.0,
1034                y: 0.0,
1035                w: 64.0,
1036                h: 64.0,
1037                fill: Color::rgb(frame as u8 * 60, 0, 0),
1038                stroke: None,
1039                stroke_width: 0.0,
1040                corner_radius: 0.0,
1041            });
1042            s
1043        })
1044        .expect("parallel pixel render failed");
1045
1046        for (i, path) in paths.iter().enumerate() {
1047            let img = image::open(path).expect("open frame").into_rgba8();
1048            let px = img.get_pixel(32, 32);
1049            let expected_r = (i as u8) * 60;
1050            assert_eq!(px[0], expected_r, "Frame {i}: red channel mismatch");
1051        }
1052        let _ = std::fs::remove_dir_all(&tmp);
1053    }
1054
1055    #[test]
1056    fn test_pipe_config_ffmpeg_args() {
1057        let config = PipeConfig::new(1920, 1080, 30.0, 10, "/tmp/out.mp4");
1058        let args = build_pipe_ffmpeg_args(&config);
1059        assert!(
1060            args.contains(&"rawvideo".to_string()),
1061            "args should contain rawvideo"
1062        );
1063        assert!(
1064            args.contains(&"1920x1080".to_string()),
1065            "args should contain resolution"
1066        );
1067        assert!(
1068            args.contains(&"pipe:0".to_string()),
1069            "args should read from stdin"
1070        );
1071        assert!(
1072            args.contains(&"/tmp/out.mp4".to_string()),
1073            "args should contain output path"
1074        );
1075        assert!(args.contains(&"-an".to_string()));
1076    }
1077
1078    #[test]
1079    fn test_codec_specific_ffmpeg_args() {
1080        let cases = [
1081            (VideoCodec::H264, "out.mp4", "libx264"),
1082            (VideoCodec::H265, "out.mp4", "libx265"),
1083            (VideoCodec::Vp9, "out.webm", "libvpx-vp9"),
1084            (VideoCodec::ProRes, "out.mov", "prores_ks"),
1085        ];
1086        for (codec, output, encoder) in cases {
1087            let config = PipeConfig::new(64, 64, 30.0, 2, output).with_codec(codec);
1088            let args = build_pipe_ffmpeg_args(&config);
1089            assert!(args.contains(&encoder.to_string()), "missing {encoder}");
1090            assert_eq!(args.last(), Some(&output.to_string()));
1091        }
1092
1093        let av1 = build_pipe_ffmpeg_args(
1094            &PipeConfig::new(64, 64, 30.0, 2, "out.webm").with_codec(VideoCodec::Av1),
1095        );
1096        assert!(av1
1097            .iter()
1098            .any(|arg| arg == "libsvtav1" || arg == "libaom-av1"));
1099
1100        let gif = build_pipe_ffmpeg_args(
1101            &PipeConfig::new(64, 64, 30.0, 2, "out.gif").with_codec(VideoCodec::Gif),
1102        );
1103        assert!(gif.iter().any(|arg| arg.contains("palettegen")));
1104        assert!(gif.contains(&"-an".to_string()));
1105    }
1106
1107    #[test]
1108    fn test_still_formats_write_decodable_images_and_report_progress() {
1109        let backend = TinySkiaBackend::headless();
1110        let temp = unique_temp_dir("stills");
1111        std::fs::create_dir_all(&temp).unwrap();
1112
1113        for (format, extension) in [
1114            (StillImageFormat::Png, "png"),
1115            (StillImageFormat::Jpeg, "jpg"),
1116            (StillImageFormat::WebP, "webp"),
1117        ] {
1118            let output = temp.join(format!("frame.{extension}"));
1119            let progress = Arc::new(Mutex::new(Vec::new()));
1120            let captured = Arc::clone(&progress);
1121            let control = RenderControl::new().with_progress(move |event| {
1122                captured.lock().unwrap().push(event);
1123            });
1124            render_still_fallible(
1125                &backend,
1126                32,
1127                24,
1128                30.0,
1129                17,
1130                &output,
1131                format,
1132                &control,
1133                |frame| {
1134                    assert_eq!(frame, 17);
1135                    Ok::<_, std::convert::Infallible>(solid_scene(Color::rgb(12, 34, 56))(frame))
1136                },
1137            )
1138            .unwrap();
1139
1140            let decoded = image::open(&output).unwrap();
1141            assert_eq!((decoded.width(), decoded.height()), (32, 24));
1142            assert_eq!(
1143                *progress.lock().unwrap(),
1144                vec![RenderProgress {
1145                    completed_frames: 1,
1146                    total_frames: 1,
1147                    frame: 17,
1148                }]
1149            );
1150        }
1151        std::fs::remove_dir_all(temp).unwrap();
1152    }
1153
1154    #[test]
1155    fn test_frame_range_streams_absolute_frames_in_order() {
1156        if !ffmpeg_available() {
1157            eprintln!("Skipping frame-range pipe test: FFmpeg is unavailable");
1158            return;
1159        }
1160        let temp = unique_temp_dir("frame_range");
1161        std::fs::create_dir_all(&temp).unwrap();
1162        let output = temp.join("range.mp4");
1163        let rendered_frames = Arc::new(Mutex::new(Vec::new()));
1164        let rendered_capture = Arc::clone(&rendered_frames);
1165        let progress = Arc::new(Mutex::new(Vec::new()));
1166        let progress_capture = Arc::clone(&progress);
1167        let control = RenderControl::new().with_progress(move |event| {
1168            progress_capture.lock().unwrap().push(event);
1169        });
1170        let config = PipeConfig::new(32, 24, 30.0, 3, &output)
1171            .with_frame_start(10)
1172            .with_concurrency(2)
1173            .with_control(control);
1174
1175        render_to_ffmpeg_pipe(&TinySkiaBackend::headless(), &config, move |frame| {
1176            rendered_capture.lock().unwrap().push(frame);
1177            solid_scene(Color::rgb(frame as u8, 20, 30))(frame)
1178        })
1179        .unwrap();
1180
1181        let mut actual_frames = rendered_frames.lock().unwrap().clone();
1182        actual_frames.sort_unstable();
1183        assert_eq!(actual_frames, vec![10, 11, 12]);
1184        assert_eq!(
1185            progress.lock().unwrap().as_slice(),
1186            &[
1187                RenderProgress {
1188                    completed_frames: 1,
1189                    total_frames: 3,
1190                    frame: 10,
1191                },
1192                RenderProgress {
1193                    completed_frames: 2,
1194                    total_frames: 3,
1195                    frame: 11,
1196                },
1197                RenderProgress {
1198                    completed_frames: 3,
1199                    total_frames: 3,
1200                    frame: 12,
1201                },
1202            ]
1203        );
1204        assert!(std::fs::metadata(&output).unwrap().len() > 0);
1205        std::fs::remove_dir_all(temp).unwrap();
1206    }
1207
1208    #[test]
1209    fn test_gif_pipe_writes_a_real_animation() {
1210        if !ffmpeg_available() {
1211            eprintln!("Skipping GIF pipe test: FFmpeg is unavailable");
1212            return;
1213        }
1214        let temp = unique_temp_dir("gif");
1215        std::fs::create_dir_all(&temp).unwrap();
1216        let output = temp.join("animation.gif");
1217        let config = PipeConfig::new(32, 24, 10.0, 2, &output).with_codec(VideoCodec::Gif);
1218        render_to_ffmpeg_pipe(&TinySkiaBackend::headless(), &config, |frame| {
1219            solid_scene(Color::rgb((frame * 100) as u8, 20, 30))(frame)
1220        })
1221        .unwrap();
1222        let bytes = std::fs::read(&output).unwrap();
1223        assert!(bytes.starts_with(b"GIF8"));
1224        std::fs::remove_dir_all(temp).unwrap();
1225    }
1226
1227    #[test]
1228    fn test_available_video_codecs_write_real_containers() {
1229        if !ffmpeg_available() {
1230            eprintln!("Skipping codec pipe test: FFmpeg is unavailable");
1231            return;
1232        }
1233        let temp = unique_temp_dir("codecs");
1234        std::fs::create_dir_all(&temp).unwrap();
1235        let av1_encoder = if ffmpeg_has_encoder("libsvtav1") {
1236            "libsvtav1"
1237        } else {
1238            "libaom-av1"
1239        };
1240        let cases = [
1241            (VideoCodec::H264, "h264.mp4", "libx264"),
1242            (VideoCodec::H265, "h265.mp4", "libx265"),
1243            (VideoCodec::Vp9, "vp9.webm", "libvpx-vp9"),
1244            (VideoCodec::Av1, "av1.webm", av1_encoder),
1245            (VideoCodec::ProRes, "prores.mov", "prores_ks"),
1246        ];
1247
1248        for (codec, file_name, encoder) in cases {
1249            if !ffmpeg_has_encoder(encoder) {
1250                eprintln!("Skipping {codec:?} pipe test: {encoder} is unavailable");
1251                continue;
1252            }
1253            let output = temp.join(file_name);
1254            let config = PipeConfig::new(64, 64, 1.0, 1, &output)
1255                .with_codec(codec)
1256                .with_concurrency(1)
1257                .with_quality(35, "fast");
1258            render_to_ffmpeg_pipe(
1259                &TinySkiaBackend::headless(),
1260                &config,
1261                solid_scene(Color::rgb(40, 80, 120)),
1262            )
1263            .unwrap_or_else(|error| panic!("{codec:?} encode failed: {error}"));
1264
1265            let bytes = std::fs::read(&output).unwrap();
1266            assert!(!bytes.is_empty(), "{codec:?} output is empty");
1267            match codec {
1268                VideoCodec::H264 | VideoCodec::H265 | VideoCodec::ProRes => {
1269                    assert_eq!(&bytes[4..8], b"ftyp", "{codec:?} is not ISO BMFF")
1270                }
1271                VideoCodec::Vp9 | VideoCodec::Av1 => {
1272                    assert!(bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]))
1273                }
1274                VideoCodec::Gif => unreachable!(),
1275            }
1276        }
1277        std::fs::remove_dir_all(temp).unwrap();
1278    }
1279
1280    #[test]
1281    fn test_cancelled_and_timed_out_render_stop_before_ffmpeg() {
1282        let temp = unique_temp_dir("control");
1283        std::fs::create_dir_all(&temp).unwrap();
1284        let cancelled_output = temp.join("cancelled.mp4");
1285        let cancelled_control = RenderControl::new();
1286        cancelled_control.cancellation_token().cancel();
1287        let cancelled =
1288            PipeConfig::new(32, 24, 30.0, 2, &cancelled_output).with_control(cancelled_control);
1289        let error = render_to_ffmpeg_pipe(
1290            &TinySkiaBackend::headless(),
1291            &cancelled,
1292            solid_scene(Color::rgb(1, 2, 3)),
1293        )
1294        .unwrap_err();
1295        assert!(matches!(error, RasterError::Cancelled));
1296        assert!(!cancelled_output.exists());
1297
1298        let timeout_output = temp.join("timeout.mp4");
1299        let timed_out = PipeConfig::new(32, 24, 30.0, 2, &timeout_output)
1300            .with_control(RenderControl::new().with_timeout(Duration::ZERO));
1301        let error = render_to_ffmpeg_pipe(
1302            &TinySkiaBackend::headless(),
1303            &timed_out,
1304            solid_scene(Color::rgb(1, 2, 3)),
1305        )
1306        .unwrap_err();
1307        assert!(matches!(error, RasterError::Timeout));
1308        assert!(!timeout_output.exists());
1309        std::fs::remove_dir_all(temp).unwrap();
1310    }
1311
1312    #[test]
1313    fn test_audio_filter_args_mix_tracks() {
1314        let mut first = AudioTrack::new("first.wav");
1315        first.start_from = 0.25;
1316        first.timeline_start = 0.5;
1317        first.volume = 0.75;
1318        let mut second = AudioTrack::new("second.wav");
1319        second.looped = true;
1320        second.playback_rate = 1.25;
1321        let config = PipeConfig::new(1920, 1080, 30.0, 90, "/tmp/out.mp4")
1322            .with_audio_tracks([first, second]);
1323
1324        let args = build_pipe_ffmpeg_args(&config);
1325        let filter_index = args
1326            .iter()
1327            .position(|arg| arg == "-filter_complex")
1328            .unwrap();
1329        let filter = &args[filter_index + 1];
1330        assert!(filter.contains("adelay=500:all=1"));
1331        assert!(filter.contains("atempo=1.250000000"));
1332        assert!(filter.contains("amix=inputs=2"));
1333        assert!(args.contains(&"-stream_loop".to_string()));
1334        assert!(args.contains(&"[aout]".to_string()));
1335    }
1336
1337    #[test]
1338    fn test_audio_filter_trims_against_selected_frame_range() {
1339        let mut track = AudioTrack::new("track.wav");
1340        track.start_from = 0.25;
1341        track.duration = Some(5.0);
1342        let config = PipeConfig::new(64, 64, 30.0, 30, "out.mp4")
1343            .with_frame_start(60)
1344            .with_audio_tracks([track]);
1345        let args = build_pipe_ffmpeg_args(&config);
1346        let filter_index = args
1347            .iter()
1348            .position(|arg| arg == "-filter_complex")
1349            .unwrap();
1350        let filter = &args[filter_index + 1];
1351        assert!(filter.contains("atrim=start=2.250000000"));
1352        assert!(filter.contains("atrim=duration=3.000000000"));
1353        assert!(filter.contains("atrim=duration=1.000000000"));
1354        assert!(!filter.contains("adelay="));
1355    }
1356
1357    #[test]
1358    fn test_audio_track_validation_rejects_invalid_volume() {
1359        let mut track = AudioTrack::new(std::env::current_exe().unwrap().display().to_string());
1360        track.volume = 1.5;
1361        let error = validate_audio_tracks(&[track]).unwrap_err();
1362        assert!(error.to_string().contains("volume must be between"));
1363    }
1364
1365    #[test]
1366    fn test_frame_timed() {
1367        let backend = TinySkiaBackend::headless();
1368        let mut scene = Scene::new();
1369        scene.push(SceneNode::Circle {
1370            cx: 32.0,
1371            cy: 32.0,
1372            r: 20.0,
1373            fill: Color::rgb(255, 128, 0),
1374            stroke: None,
1375            stroke_width: 0.0,
1376        });
1377        let config = FrameConfig::new(64, 64, 0, 30.0);
1378
1379        let (img, elapsed) =
1380            render_frame_timed(&backend, &scene, &config).expect("timed render failed");
1381        assert_eq!(img.width(), 64);
1382        println!("Single 64x64 frame: {:?}", elapsed);
1383    }
1384}