Skip to main content

dioxuscut_renderer/
encode.rs

1//! Video encoding — stitches rendered frame PNGs into a video file via FFmpeg.
2
3use crate::render_frames::RenderError;
4use std::path::{Path, PathBuf};
5
6/// Configuration for the encoding step.
7#[derive(Debug, Clone)]
8pub struct EncodeConfig {
9    /// Directory containing `frame_%06d.png` files.
10    pub frames_dir: PathBuf,
11    /// Output video file path.
12    pub output: PathBuf,
13    /// Frames per second.
14    pub fps: f64,
15    /// CRF quality (lower = better; 0–51 for H.264).
16    pub crf: u32,
17    /// FFmpeg preset (e.g., `"fast"`, `"medium"`, `"ultrafast"`).
18    pub preset: String,
19    /// Video codec string for FFmpeg (e.g., `"libx264"`).
20    pub codec: String,
21    /// Pixel format string for FFmpeg (e.g., `"yuv420p"`).
22    pub pixel_format: String,
23    /// Video width in pixels (optional).
24    pub width: Option<u32>,
25    /// Video height in pixels (optional).
26    pub height: Option<u32>,
27    /// MP4 container flags for web optimization (e.g., `"+faststart"`).
28    pub movflags: String,
29    /// Automatically remove temporary frame directory after encoding succeeds.
30    pub cleanup_after_encode: bool,
31}
32
33impl EncodeConfig {
34    /// Create an H.264 config with sensible defaults.
35    pub fn h264(frames_dir: impl Into<PathBuf>, output: impl Into<PathBuf>, fps: f64) -> Self {
36        Self {
37            frames_dir: frames_dir.into(),
38            output: output.into(),
39            fps,
40            crf: 18,
41            preset: "fast".to_string(),
42            codec: "libx264".to_string(),
43            pixel_format: "yuv420p".to_string(),
44            width: None,
45            height: None,
46            movflags: "+faststart".to_string(),
47            cleanup_after_encode: false,
48        }
49    }
50
51    /// Set video resolution width and height.
52    pub fn with_resolution(mut self, width: u32, height: u32) -> Self {
53        self.width = Some(width);
54        self.height = Some(height);
55        self
56    }
57
58    /// Set encoding preset.
59    pub fn with_preset(mut self, preset: impl Into<String>) -> Self {
60        self.preset = preset.into();
61        self
62    }
63
64    /// Enable or disable cleanup of frame directory after encoding.
65    pub fn with_cleanup(mut self, cleanup: bool) -> Self {
66        self.cleanup_after_encode = cleanup;
67        self
68    }
69}
70
71/// Constructs FFmpeg CLI argument list from an [`EncodeConfig`].
72pub fn build_ffmpeg_args(config: &EncodeConfig) -> Vec<String> {
73    let pattern_file = if config.frames_dir.join("frame-000000.png").exists() {
74        "frame-%06d.png"
75    } else {
76        "frame_%06d.png"
77    };
78
79    let input_pattern = config
80        .frames_dir
81        .join(pattern_file)
82        .to_string_lossy()
83        .to_string();
84
85    let mut args = vec![
86        "-y".to_string(),
87        "-framerate".to_string(),
88        config.fps.to_string(),
89        "-i".to_string(),
90        input_pattern,
91        "-c:v".to_string(),
92        config.codec.clone(),
93        "-crf".to_string(),
94        config.crf.to_string(),
95        "-preset".to_string(),
96        config.preset.clone(),
97        "-pix_fmt".to_string(),
98        config.pixel_format.clone(),
99    ];
100
101    if let (Some(w), Some(h)) = (config.width, config.height) {
102        args.push("-s".to_string());
103        args.push(format!("{w}x{h}"));
104    }
105
106    args.push("-vf".to_string());
107    args.push("scale=trunc(iw/2)*2:trunc(ih/2)*2".to_string());
108
109    if !config.movflags.is_empty() {
110        args.push("-movflags".to_string());
111        args.push(config.movflags.clone());
112    }
113
114    args.push(config.output.to_string_lossy().to_string());
115    args
116}
117
118/// Encode rendered PNG frames into an MP4 video file using FFmpeg.
119///
120/// Executes the command:
121/// `ffmpeg -y -framerate <fps> -i <frames_dir>/frame_%06d.png -c:v libx264 -crf 18 -preset fast -pix_fmt yuv420p -s <width>x<height> -movflags +faststart <output_mp4>`
122///
123/// Logs invocation details and captures stderr output using `tracing`.
124pub async fn encode_mp4(config: &EncodeConfig) -> Result<(), RenderError> {
125    let args = build_ffmpeg_args(config);
126    let cmd_line = format!("ffmpeg {}", args.join(" "));
127
128    tracing::info!("Invoking FFmpeg CLI: {cmd_line}");
129
130    let output = tokio::process::Command::new("ffmpeg")
131        .args(&args)
132        .stdout(std::process::Stdio::piped())
133        .stderr(std::process::Stdio::piped())
134        .output()
135        .await
136        .map_err(|e| RenderError::Encode(format!("Failed to execute ffmpeg process: {e}")))?;
137
138    let stderr_text = String::from_utf8_lossy(&output.stderr);
139
140    if !output.status.success() {
141        tracing::error!(
142            "FFmpeg execution failed with status {}\nCommand: {}\nStderr:\n{}",
143            output.status,
144            cmd_line,
145            stderr_text
146        );
147        return Err(RenderError::Encode(format!(
148            "ffmpeg exited with status {}: {}",
149            output.status,
150            stderr_text.trim()
151        )));
152    }
153
154    if !stderr_text.is_empty() {
155        tracing::info!("FFmpeg output:\n{}", stderr_text);
156    }
157    tracing::info!("Encode complete → {}", config.output.display());
158
159    if config.cleanup_after_encode {
160        cleanup_frames(&config.frames_dir)?;
161    }
162
163    Ok(())
164}
165
166/// Encode rendered frames into a video file using FFmpeg.
167pub async fn encode_frames(config: &EncodeConfig) -> Result<(), RenderError> {
168    encode_mp4(config).await
169}
170
171/// Remove temporary frame directory and its contents.
172pub fn cleanup_frames(frames_dir: impl AsRef<Path>) -> Result<(), RenderError> {
173    let path = frames_dir.as_ref();
174    if path.exists() {
175        tracing::info!("Cleaning up temporary frames directory: {}", path.display());
176        std::fs::remove_dir_all(path)?;
177    }
178    Ok(())
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use std::fs;
185
186    const MINIMAL_PNG: &[u8] = &[
187        0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
188        0x52, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, 0x08, 0x02, 0x00, 0x00, 0x00, 0x4b,
189        0x6d, 0x29, 0xdc, 0x00, 0x00, 0x00, 0x12, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8,
190        0xcf, 0xc0, 0x80, 0x15, 0x61, 0x17, 0x1d, 0xb4, 0x12, 0x00, 0x28, 0xff, 0x3f, 0xc1, 0x6e,
191        0xec, 0xdf, 0x61, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
192    ];
193
194    #[test]
195    fn test_build_ffmpeg_args_default() {
196        let frames_dir = PathBuf::from("/tmp/frames");
197        let output = PathBuf::from("/tmp/output.mp4");
198        let config = EncodeConfig::h264(&frames_dir, &output, 30.0);
199
200        let args = build_ffmpeg_args(&config);
201        let input_pattern = frames_dir.join("frame_%06d.png");
202        let input_pattern = input_pattern.to_string_lossy();
203        assert_eq!(
204            args,
205            vec![
206                "-y",
207                "-framerate",
208                "30",
209                "-i",
210                input_pattern.as_ref(),
211                "-c:v",
212                "libx264",
213                "-crf",
214                "18",
215                "-preset",
216                "fast",
217                "-pix_fmt",
218                "yuv420p",
219                "-vf",
220                "scale=trunc(iw/2)*2:trunc(ih/2)*2",
221                "-movflags",
222                "+faststart",
223                "/tmp/output.mp4"
224            ]
225        );
226    }
227
228    #[test]
229    fn test_build_ffmpeg_args_with_resolution() {
230        let frames_dir = PathBuf::from("/tmp/frames");
231        let output = PathBuf::from("/tmp/output.mp4");
232        let config = EncodeConfig::h264(&frames_dir, &output, 60.0)
233            .with_resolution(1920, 1080)
234            .with_preset("medium");
235
236        let args = build_ffmpeg_args(&config);
237        let input_pattern = frames_dir.join("frame_%06d.png");
238        let input_pattern = input_pattern.to_string_lossy();
239        assert_eq!(
240            args,
241            vec![
242                "-y",
243                "-framerate",
244                "60",
245                "-i",
246                input_pattern.as_ref(),
247                "-c:v",
248                "libx264",
249                "-crf",
250                "18",
251                "-preset",
252                "medium",
253                "-pix_fmt",
254                "yuv420p",
255                "-s",
256                "1920x1080",
257                "-vf",
258                "scale=trunc(iw/2)*2:trunc(ih/2)*2",
259                "-movflags",
260                "+faststart",
261                "/tmp/output.mp4"
262            ]
263        );
264    }
265
266    #[test]
267    fn test_cleanup_frames() {
268        let temp_dir = std::env::temp_dir().join(format!(
269            "dioxuscut_test_cleanup_{}",
270            std::time::SystemTime::now()
271                .duration_since(std::time::UNIX_EPOCH)
272                .unwrap()
273                .as_nanos()
274        ));
275        fs::create_dir_all(&temp_dir).unwrap();
276        let frame1 = temp_dir.join("frame_000000.png");
277        fs::write(&frame1, b"fake_png_data").unwrap();
278        assert!(frame1.exists());
279
280        cleanup_frames(&temp_dir).expect("cleanup_frames failed");
281        assert!(!temp_dir.exists());
282    }
283
284    #[tokio::test]
285    async fn test_encode_mp4_synthetic_frames() {
286        let temp_dir = std::env::temp_dir().join(format!(
287            "dioxuscut_test_encode_{}",
288            std::time::SystemTime::now()
289                .duration_since(std::time::UNIX_EPOCH)
290                .unwrap()
291                .as_nanos()
292        ));
293        let frames_dir = temp_dir.join("frames");
294        fs::create_dir_all(&frames_dir).unwrap();
295
296        for idx in 0..10 {
297            let path = frames_dir.join(format!("frame_{idx:06}.png"));
298            fs::write(&path, MINIMAL_PNG).expect("Failed to write synthetic PNG frame");
299        }
300
301        let output_mp4 = temp_dir.join("output.mp4");
302        let config = EncodeConfig::h264(&frames_dir, &output_mp4, 30.0)
303            .with_resolution(320, 240)
304            .with_cleanup(true);
305
306        let res = encode_mp4(&config).await;
307
308        if let Err(ref e) = res {
309            eprintln!("encode_mp4 failed: {e:?}");
310        }
311
312        assert!(res.is_ok(), "encode_mp4 failed on synthetic frames");
313        assert!(output_mp4.exists(), "Output MP4 file was not created");
314        assert!(!frames_dir.exists(), "Frames directory was not cleaned up");
315
316        let _ = fs::remove_dir_all(&temp_dir);
317    }
318}