1use crate::render_frames::RenderError;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone)]
8pub struct EncodeConfig {
9 pub frames_dir: PathBuf,
11 pub output: PathBuf,
13 pub fps: f64,
15 pub crf: u32,
17 pub preset: String,
19 pub codec: String,
21 pub pixel_format: String,
23 pub width: Option<u32>,
25 pub height: Option<u32>,
27 pub movflags: String,
29 pub cleanup_after_encode: bool,
31}
32
33impl EncodeConfig {
34 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 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 pub fn with_preset(mut self, preset: impl Into<String>) -> Self {
60 self.preset = preset.into();
61 self
62 }
63
64 pub fn with_cleanup(mut self, cleanup: bool) -> Self {
66 self.cleanup_after_encode = cleanup;
67 self
68 }
69}
70
71pub 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
118pub 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
166pub async fn encode_frames(config: &EncodeConfig) -> Result<(), RenderError> {
168 encode_mp4(config).await
169}
170
171pub 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}