1use 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#[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 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#[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#[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 pub start_frame: u32,
177 pub output: PathBuf,
179 pub concurrency: Option<usize>,
181 pub crf: u32,
183 pub preset: String,
185 pub codec: VideoCodec,
186 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#[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
295pub 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
329pub 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 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 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 pairs.sort_by_key(|(f, _)| *f);
383
384 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
396pub 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
431pub 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 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 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 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); if let Err(error) = render_result {
547 let _ = ffmpeg.kill();
548 let _ = ffmpeg.wait();
549 return Err(error);
550 }
551
552 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
652pub fn build_pipe_ffmpeg_args(config: &PipeConfig) -> Vec<String> {
654 let mut args = vec![
655 "-y".into(), "-loglevel".into(),
657 "error".into(),
658 "-f".into(),
659 "rawvideo".into(), "-pix_fmt".into(),
661 "rgba".into(), "-s".into(),
663 format!("{}x{}", config.width, config.height),
664 "-r".into(),
665 format!("{}", config.fps),
666 "-i".into(),
667 "pipe:0".into(), ];
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
907pub fn save_frame(img: &RgbaImage, path: &Path) -> Result<(), RasterError> {
909 img.save(path)
910 .map_err(|e| RasterError::ImageEncode(e.to_string()))
911}
912
913pub 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 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 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}