Skip to main content

mcraw_tui/
encoder.rs

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        // Add audio input from temp file if available
128        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        // Append dynamic extra args (profile, crf, preset, color tags,
143        // scale filter for wide-gamut YUV conversion, etc.).
144        // The scale filter (if needed) is injected by to_ffmpeg_args in
145        // export.rs — we pass it through as part of extra_args.
146        cmd.args(extra_args);
147
148        // Move the `moov` atom to the front of the file on finalize. Without
149        // this, the MP4/MOV muxer writes `moov` after all `mdat` chunks, so
150        // players (VLC, mpv, browser <video>) have to scan the whole file
151        // before they can seek or start playback. Cost: ~1-2 s at finalize.
152        // Harmless for codecs that don't use a moov box (DNG, raw streams).
153        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        // NOTE: VUI signalling (color_primaries / color_trc / colorspace) is
160        // passed through `extra_args` and propagates automatically to libx264
161        // and libx265. We deliberately do **not** emit hard-coded
162        // `-x264-params colorprim=bt709:...` / `-x265-params colorprim=bt709:...`
163        // here because it would override the user's chosen gamut/transfer.
164
165        // Add audio encoder if audio input is present
166        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        // Capture FFmpeg stderr to a temp log file so we can surface real
176        // errors back to the user. The previous `/dev/null` redirect made
177        // failures appear as opaque "FFmpeg stdin not available" messages.
178        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    /// Read the captured FFmpeg stderr log (best-effort) and return the
204    /// final ~2 KB. Used to enrich error messages with the actual reason
205    /// FFmpeg failed.
206    fn tail_stderr(&self) -> String {
207        Self::tail_stderr_from(&self.stderr_log_path)
208    }
209
210    /// Free-function variant of `tail_stderr` so callers that already hold
211    /// a mutable borrow on `self.child` (e.g. inside `as_mut().ok_or_else(...)`)
212    /// can still pull stderr without re-borrowing `self`.
213    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        // Capture the stderr-log path up front so the error-handling
226        // closure does not need to re-borrow `self` while `self.child.stdin`
227        // is being borrowed mutably.
228        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            // Successful run — clean up the stderr log.
257            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    /// Force-terminate the FFmpeg subprocess. Used during cancellation to
270    /// unblock a writer thread that may be stuck in `push_frame()`.
271    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    /// Returns the OS process ID of the FFmpeg subprocess.
278    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        // Best-effort cleanup; the log may have already been removed by
291        // `finish()` on success, or kept by an error path for diagnostics.
292        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}