1use anyhow::Result;
2use std::path::{Path, PathBuf};
3use std::process::{Child, Command, Stdio};
4
5#[derive(Debug, Clone)]
6pub enum OutputFormat {
7 DNG { output_path: PathBuf },
8 ProRes { output_path: PathBuf },
9 H264 { output_path: PathBuf },
10 HEVC { output_path: PathBuf },
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum EncodeStatus {
15 Queued,
16 Running,
17 Completed,
18 Failed(String),
19}
20
21#[derive(Debug, Clone)]
22pub struct EncodeJob {
23 pub id: String,
24 pub format: OutputFormat,
25 pub status: EncodeStatus,
26 pub progress: f64,
27 pub error: Option<String>,
28}
29
30impl EncodeJob {
31 pub fn new(id: String, format: OutputFormat) -> Self {
32 EncodeJob {
33 id,
34 format,
35 status: EncodeStatus::Queued,
36 progress: 0.0,
37 error: None,
38 }
39 }
40
41 pub fn is_complete(&self) -> bool {
42 matches!(self.status, EncodeStatus::Completed)
43 }
44
45 pub fn is_failed(&self) -> bool {
46 matches!(self.status, EncodeStatus::Failed(_))
47 }
48
49 pub fn is_running(&self) -> bool {
50 matches!(self.status, EncodeStatus::Running)
51 }
52
53 pub fn format_label(&self) -> &'static str {
54 match &self.format {
55 OutputFormat::DNG { .. } => "DNG",
56 OutputFormat::ProRes { .. } => "ProRes",
57 OutputFormat::H264 { .. } => "H.264",
58 OutputFormat::HEVC { .. } => "HEVC",
59 }
60 }
61
62 pub fn output_path(&self) -> Option<&PathBuf> {
63 match &self.format {
64 OutputFormat::DNG { output_path } => Some(output_path),
65 OutputFormat::ProRes { output_path } => Some(output_path),
66 OutputFormat::H264 { output_path } => Some(output_path),
67 OutputFormat::HEVC { output_path } => Some(output_path),
68 }
69 }
70}
71
72pub struct Encoder;
73
74impl Encoder {
75 pub fn new() -> Self {
76 tracing::info!("encoder stub initialized");
77 Encoder {}
78 }
79
80 pub async fn start_job(&self, job: EncodeJob) -> Result<()> {
81 tracing::info!("[stub] starting encode job: {} -> {:?}", job.id, job.format);
82 Ok(())
83 }
84
85 pub async fn cancel_job(&self, _job_id: &str) -> Result<()> {
86 tracing::info!("[stub] canceling encode job: {}", _job_id);
87 Ok(())
88 }
89
90 pub async fn list_supported_formats(&self) -> Vec<&'static str> {
91 vec!["DNG", "ProRes", "H.264", "HEVC"]
92 }
93}
94
95impl Default for Encoder {
96 fn default() -> Self {
97 Self::new()
98 }
99}
100
101pub struct VideoEncoder {
102 child: Child,
103 audio_temp_path: Option<PathBuf>,
104 stderr_log_path: PathBuf,
105}
106
107impl VideoEncoder {
108 #[allow(clippy::too_many_arguments)]
109 pub fn new(
110 output_path: &str, width: u32, height: u32, fps: f64,
111 codec: &str, pix_fmt: &str, extra_args: &[String],
112 audio_temp_path: Option<&Path>,
113 audio_sample_rate: u32,
114 audio_channels: u16,
115 ) -> Result<Self> {
116 const INPUT_PIX_FMT: &str = "rgb48le";
117
118 let mut cmd = Command::new("ffmpeg");
119 cmd.args([
120 "-f", "rawvideo",
121 "-pix_fmt", INPUT_PIX_FMT,
122 "-s", &format!("{}x{}", width, height),
123 "-r", &format!("{}", fps),
124 "-i", "-",
125 ]);
126
127 if let Some(audio_path) = audio_temp_path {
129 cmd.args([
130 "-f", "s16le",
131 "-ar", &audio_sample_rate.to_string(),
132 "-ac", &audio_channels.to_string(),
133 "-i", &audio_path.to_string_lossy(),
134 ]);
135 }
136
137 cmd.args([
138 "-c:v", codec,
139 "-pix_fmt", pix_fmt,
140 ]);
141
142 cmd.args(extra_args);
147
148 if output_path.to_lowercase().ends_with(".mp4")
154 || output_path.to_lowercase().ends_with(".mov")
155 {
156 cmd.args(["-movflags", "+faststart"]);
157 }
158
159 if audio_temp_path.is_some() {
167 let audio_codec = if output_path.to_lowercase().ends_with(".mov") {
168 "pcm_s16le"
169 } else {
170 "aac"
171 };
172 cmd.args(["-c:a", audio_codec]);
173 }
174
175 let ts = std::time::SystemTime::now()
179 .duration_since(std::time::UNIX_EPOCH)
180 .unwrap_or_default()
181 .as_nanos();
182 let stderr_log_path = std::env::temp_dir()
183 .join(format!("mcraw_ffmpeg_stderr_{}.log", ts));
184 let stderr_file = std::fs::File::create(&stderr_log_path)
185 .map_err(|e| anyhow::anyhow!("Failed to create ffmpeg stderr log: {}", e))?;
186
187 cmd.arg("-y").arg(output_path)
188 .stdin(Stdio::piped())
189 .stdout(Stdio::null())
190 .stderr(Stdio::from(stderr_file));
191
192 let child = cmd.spawn()?;
193 tracing::info!("ffmpeg subprocess spawned: pid={} codec={} {}x{}@{}fps output={} stderr_log={}",
194 child.id(), codec, width, height, fps, output_path, stderr_log_path.display());
195
196 Ok(Self {
197 child,
198 audio_temp_path: audio_temp_path.map(|p| p.to_path_buf()),
199 stderr_log_path,
200 })
201 }
202
203 fn tail_stderr(&self) -> String {
207 Self::tail_stderr_from(&self.stderr_log_path)
208 }
209
210 fn tail_stderr_from(path: &Path) -> String {
214 const TAIL_BYTES: usize = 2048;
215 let bytes = match std::fs::read(path) {
216 Ok(b) => b,
217 Err(_) => return String::new(),
218 };
219 let start = bytes.len().saturating_sub(TAIL_BYTES);
220 String::from_utf8_lossy(&bytes[start..]).trim().to_string()
221 }
222
223 pub fn push_frame(&mut self, data: &[u8]) -> Result<()> {
224 use std::io::Write;
225 let stderr_path = self.stderr_log_path.clone();
229 let stdin = self.child.stdin.as_mut().ok_or_else(|| {
230 tracing::error!("ffmpeg stdin not available");
231 let stderr_tail = Self::tail_stderr_from(&stderr_path);
232 if stderr_tail.is_empty() {
233 anyhow::anyhow!("FFmpeg stdin not available (process may have crashed)")
234 } else {
235 anyhow::anyhow!("FFmpeg failed:\n{}", stderr_tail)
236 }
237 })?;
238 if let Err(e) = stdin.write_all(data) {
239 let stderr_tail = Self::tail_stderr_from(&stderr_path);
240 tracing::error!("ffmpeg push_frame error: {} | stderr: {}", e, stderr_tail);
241 if stderr_tail.is_empty() {
242 return Err(anyhow::anyhow!("FFmpeg write failed: {}", e));
243 } else {
244 return Err(anyhow::anyhow!("FFmpeg failed:\n{}", stderr_tail));
245 }
246 }
247 Ok(())
248 }
249
250 pub fn finish(&mut self) -> Result<()> {
251 tracing::debug!("ffmpeg finish: closing stdin and waiting");
252 drop(self.child.stdin.take());
253 let status = self.child.wait()?;
254 tracing::info!("ffmpeg subprocess exited: {}", status);
255 if status.success() {
256 let _ = std::fs::remove_file(&self.stderr_log_path);
258 Ok(())
259 } else {
260 let stderr_tail = self.tail_stderr();
261 if stderr_tail.is_empty() {
262 Err(anyhow::anyhow!("FFmpeg exited with status: {}", status))
263 } else {
264 Err(anyhow::anyhow!("FFmpeg exited with status {}:\n{}", status, stderr_tail))
265 }
266 }
267 }
268
269 pub fn kill(&mut self) {
272 tracing::debug!("ffmpeg kill: terminating subprocess");
273 let _ = self.child.stdin.take();
274 let _ = self.child.kill();
275 }
276
277 pub fn pid(&self) -> u32 {
279 self.child.id()
280 }
281}
282
283impl Drop for VideoEncoder {
284 fn drop(&mut self) {
285 let _ = self.child.stdin.take();
286 let _ = self.child.kill();
287 if let Some(ref path) = self.audio_temp_path {
288 let _ = std::fs::remove_file(path);
289 }
290 let _ = std::fs::remove_file(&self.stderr_log_path);
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn test_encode_job_new() {
302 let job = EncodeJob::new(
303 "test-1".to_string(),
304 OutputFormat::DNG {
305 output_path: PathBuf::from("/tmp/test.dng"),
306 },
307 );
308 assert_eq!(job.id, "test-1");
309 assert_eq!(job.status, EncodeStatus::Queued);
310 assert_eq!(job.progress, 0.0);
311 assert_eq!(job.format_label(), "DNG");
312 }
313
314 #[test]
315 fn test_encode_job_status_checks() {
316 let mut job = EncodeJob::new(
317 "test-1".to_string(),
318 OutputFormat::ProRes {
319 output_path: PathBuf::from("/tmp/test.mov"),
320 },
321 );
322
323 assert!(!job.is_complete());
324 assert!(!job.is_failed());
325 assert!(!job.is_running());
326
327 job.status = EncodeStatus::Running;
328 assert!(job.is_running());
329 assert!(!job.is_complete());
330
331 job.status = EncodeStatus::Completed;
332 assert!(job.is_complete());
333 assert!(!job.is_running());
334
335 job.status = EncodeStatus::Failed("error".to_string());
336 assert!(job.is_failed());
337 }
338
339 #[test]
340 fn test_format_labels() {
341 let dng = OutputFormat::DNG {
342 output_path: PathBuf::from("/tmp/dng"),
343 };
344 let prores = OutputFormat::ProRes {
345 output_path: PathBuf::from("/tmp/prores"),
346 };
347 let h264 = OutputFormat::H264 {
348 output_path: PathBuf::from("/tmp/h264"),
349 };
350 let hevc = OutputFormat::HEVC {
351 output_path: PathBuf::from("/tmp/hevc"),
352 };
353
354 assert_eq!(
355 EncodeJob {
356 id: "1".to_string(),
357 format: dng,
358 status: EncodeStatus::Queued,
359 progress: 0.0,
360 error: None,
361 }
362 .format_label(),
363 "DNG"
364 );
365 assert_eq!(
366 EncodeJob {
367 id: "2".to_string(),
368 format: prores,
369 status: EncodeStatus::Queued,
370 progress: 0.0,
371 error: None,
372 }
373 .format_label(),
374 "ProRes"
375 );
376 assert_eq!(
377 EncodeJob {
378 id: "3".to_string(),
379 format: h264,
380 status: EncodeStatus::Queued,
381 progress: 0.0,
382 error: None,
383 }
384 .format_label(),
385 "H.264"
386 );
387 assert_eq!(
388 EncodeJob {
389 id: "4".to_string(),
390 format: hevc,
391 status: EncodeStatus::Queued,
392 progress: 0.0,
393 error: None,
394 }
395 .format_label(),
396 "HEVC"
397 );
398 }
399}