Skip to main content

oxframe_export/
lib.rs

1//! Video export: raw RGBA frames are piped into `ffmpeg` (no intermediate
2//! files) from a writer thread, so encoding overlaps with rendering.
3//!
4//! `ffmpeg` is looked up in `PATH` (or an explicit path); if it is missing,
5//! [`ExportError::FfmpegNotFound`] carries installation instructions.
6
7#![warn(missing_docs)]
8
9use std::fmt;
10use std::io::{Read, Write};
11use std::path::{Path, PathBuf};
12use std::process::{Child, Command, Stdio};
13use std::sync::mpsc::{self, Receiver, SyncSender};
14use std::thread::JoinHandle;
15
16use oxframe_render::Image;
17
18/// Output format.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Format {
21    /// H.264, yuv420p, AAC audio.
22    Mp4,
23    /// VP9 with alpha, Opus audio.
24    Webm,
25    /// ProRes 4444 with alpha in .mov, PCM audio.
26    ProRes,
27    /// Numbered PNG files in a directory.
28    Png,
29}
30
31impl Format {
32    /// From a name or extension (`"mp4"`, `"mov"`…).
33    pub fn parse(s: &str) -> Option<Self> {
34        match s.to_ascii_lowercase().as_str() {
35            "mp4" => Some(Self::Mp4),
36            "webm" => Some(Self::Webm),
37            "prores" | "mov" => Some(Self::ProRes),
38            "png" => Some(Self::Png),
39            _ => None,
40        }
41    }
42
43    /// Guesses from the output path; a path without extension is a PNG
44    /// directory.
45    pub fn from_path(path: &Path) -> Self {
46        path.extension()
47            .and_then(|e| e.to_str())
48            .and_then(Self::parse)
49            .unwrap_or(Self::Png)
50    }
51
52    /// File extension; empty for PNG directories.
53    pub fn extension(self) -> &'static str {
54        match self {
55            Self::Mp4 => "mp4",
56            Self::Webm => "webm",
57            Self::ProRes => "mov",
58            Self::Png => "",
59        }
60    }
61}
62
63/// Soundtrack to mux into the video.
64#[derive(Debug, Clone)]
65pub struct Audio {
66    /// Audio file in any format ffmpeg reads.
67    pub file: PathBuf,
68    /// Seconds; positive delays the audio, negative skips into it.
69    pub offset: f64,
70}
71
72/// What to export and where.
73#[derive(Debug, Clone)]
74pub struct ExportOptions {
75    /// Output file, or directory for PNG.
76    pub out: PathBuf,
77    /// Output format.
78    pub format: Format,
79    /// Frame width in pixels.
80    pub width: u32,
81    /// Frame height in pixels.
82    pub height: u32,
83    /// Frames per second.
84    pub fps: u32,
85    /// Optional soundtrack (ignored for PNG).
86    pub audio: Option<Audio>,
87    /// Explicit ffmpeg binary; otherwise found in `PATH`.
88    pub ffmpeg: Option<PathBuf>,
89}
90
91/// Why an export failed.
92#[derive(Debug)]
93pub enum ExportError {
94    /// No ffmpeg binary; the message explains how to install it.
95    FfmpegNotFound,
96    /// Writing output or talking to ffmpeg failed.
97    Io(std::io::Error),
98    /// ffmpeg exited with an error.
99    Ffmpeg {
100        /// Exit status.
101        status: String,
102        /// The end of ffmpeg's stderr.
103        log: String,
104    },
105    /// MP4 needs even dimensions.
106    BadSize {
107        /// Requested width.
108        width: u32,
109        /// Requested height.
110        height: u32,
111    },
112}
113
114impl fmt::Display for ExportError {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        match self {
117            Self::FfmpegNotFound => f.write_str(FFMPEG_HELP),
118            Self::Io(e) => write!(f, "{e}"),
119            Self::Ffmpeg { status, log } => write!(f, "ffmpeg failed ({status}):\n{log}"),
120            Self::BadSize { width, height } => write!(
121                f,
122                "{width}x{height}: mp4 (yuv420p) needs even width and height"
123            ),
124        }
125    }
126}
127
128impl std::error::Error for ExportError {
129    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
130        match self {
131            Self::Io(e) => Some(e),
132            _ => None,
133        }
134    }
135}
136
137impl From<std::io::Error> for ExportError {
138    fn from(e: std::io::Error) -> Self {
139        Self::Io(e)
140    }
141}
142
143/// Installation hint shown when ffmpeg is missing.
144pub const FFMPEG_HELP: &str = "\
145ffmpeg was not found in PATH. Install it and try again:
146  Linux:   sudo apt install ffmpeg      (or dnf/pacman install ffmpeg)
147  macOS:   brew install ffmpeg
148  Windows: winget install Gyan.FFmpeg   (then reopen the terminal)
149or set `ffmpeg = \"/path/to/ffmpeg\"` in oxframe.toml / pass --ffmpeg <path>.
150PNG sequences (--format png) do not need ffmpeg.";
151
152/// Finds `ffmpeg`: the explicit path if given, else the first match in
153/// `PATH` (with `.exe` on Windows).
154///
155/// # Errors
156///
157/// [`ExportError::FfmpegNotFound`] if there is no such binary.
158pub fn find_ffmpeg(explicit: Option<&Path>) -> Result<PathBuf, ExportError> {
159    if let Some(p) = explicit {
160        return if p.is_file() {
161            Ok(p.to_path_buf())
162        } else {
163            Err(ExportError::FfmpegNotFound)
164        };
165    }
166    let name = if cfg!(windows) {
167        "ffmpeg.exe"
168    } else {
169        "ffmpeg"
170    };
171    std::env::var_os("PATH")
172        .and_then(|paths| {
173            std::env::split_paths(&paths)
174                .map(|d| d.join(name))
175                .find(|p| p.is_file())
176        })
177        .ok_or(ExportError::FfmpegNotFound)
178}
179
180/// Receives frames in order and writes them out.
181pub struct Exporter {
182    tx: Option<SyncSender<Image>>,
183    worker: Option<JoinHandle<Result<(), ExportError>>>,
184}
185
186enum Sink {
187    Ffmpeg(Child),
188    Png { dir: PathBuf, next: u32 },
189}
190
191impl Exporter {
192    /// Starts ffmpeg (or creates the PNG directory).
193    ///
194    /// # Errors
195    ///
196    /// If ffmpeg is missing or cannot start, the output cannot be created,
197    /// or the size is odd for MP4.
198    pub fn start(opts: &ExportOptions) -> Result<Self, ExportError> {
199        let sink = match opts.format {
200            Format::Png => {
201                std::fs::create_dir_all(&opts.out)?;
202                Sink::Png {
203                    dir: opts.out.clone(),
204                    next: 0,
205                }
206            }
207            _ => {
208                if opts.format == Format::Mp4
209                    && (!opts.width.is_multiple_of(2) || !opts.height.is_multiple_of(2))
210                {
211                    return Err(ExportError::BadSize {
212                        width: opts.width,
213                        height: opts.height,
214                    });
215                }
216                let ffmpeg = find_ffmpeg(opts.ffmpeg.as_deref())?;
217                if let Some(dir) = opts.out.parent().filter(|d| !d.as_os_str().is_empty()) {
218                    std::fs::create_dir_all(dir)?;
219                }
220                let child = Command::new(ffmpeg)
221                    .args(ffmpeg_args(opts))
222                    .stdin(Stdio::piped())
223                    .stdout(Stdio::null())
224                    .stderr(Stdio::piped())
225                    .spawn()?;
226                Sink::Ffmpeg(child)
227            }
228        };
229        // A small bounded queue: rendering runs ahead of encoding by at
230        // most a few frames, keeping memory flat.
231        let (tx, rx) = mpsc::sync_channel::<Image>(4);
232        let worker = std::thread::Builder::new()
233            .name("oxframe-export".into())
234            .spawn(move || write_frames(sink, rx))?;
235        Ok(Self {
236            tx: Some(tx),
237            worker: Some(worker),
238        })
239    }
240
241    /// Queues a frame. Fails early if the writer has stopped (e.g. ffmpeg
242    /// exited).
243    ///
244    /// # Errors
245    ///
246    /// If the writer stopped; [`finish`](Self::finish) then reports why.
247    pub fn push(&mut self, frame: Image) -> Result<(), ExportError> {
248        let tx = self.tx.as_ref().expect("push after finish");
249        if tx.send(frame).is_err() {
250            return self.finish();
251        }
252        Ok(())
253    }
254
255    /// Flushes, waits for ffmpeg and reports its errors.
256    ///
257    /// # Errors
258    ///
259    /// If writing failed or ffmpeg exited with an error (its log is included).
260    pub fn finish(&mut self) -> Result<(), ExportError> {
261        drop(self.tx.take());
262        match self.worker.take() {
263            Some(w) => w.join().unwrap_or_else(|_| {
264                Err(ExportError::Io(std::io::Error::other(
265                    "export thread panicked",
266                )))
267            }),
268            None => Ok(()),
269        }
270    }
271}
272
273impl Drop for Exporter {
274    fn drop(&mut self) {
275        let _ = self.finish();
276    }
277}
278
279fn write_frames(sink: Sink, rx: Receiver<Image>) -> Result<(), ExportError> {
280    match sink {
281        Sink::Png { dir, mut next } => {
282            for frame in rx {
283                std::fs::write(dir.join(format!("{next:06}.png")), frame.encode_png())?;
284                next += 1;
285            }
286            Ok(())
287        }
288        Sink::Ffmpeg(mut child) => {
289            let mut stdin = child.stdin.take().expect("piped stdin");
290            let mut stderr = child.stderr.take().expect("piped stderr");
291            // Drain stderr concurrently so ffmpeg never blocks on it.
292            let log = std::thread::spawn(move || {
293                let mut s = String::new();
294                let _ = stderr.read_to_string(&mut s);
295                s
296            });
297            let mut write_err = None;
298            for frame in rx {
299                if let Err(e) = stdin.write_all(&frame.data) {
300                    write_err = Some(e);
301                    break;
302                }
303            }
304            drop(stdin);
305            let status = child.wait()?;
306            let log = log.join().unwrap_or_default();
307            if !status.success() || write_err.is_some() {
308                let tail: Vec<&str> = log.lines().rev().take(15).collect();
309                return Err(ExportError::Ffmpeg {
310                    status: status.to_string(),
311                    log: tail.into_iter().rev().collect::<Vec<_>>().join("\n"),
312                });
313            }
314            Ok(())
315        }
316    }
317}
318
319/// Command line for ffmpeg (without the program name).
320pub fn ffmpeg_args(o: &ExportOptions) -> Vec<String> {
321    let mut a: Vec<String> = ["-hide_banner", "-loglevel", "error", "-y"]
322        .iter()
323        .map(|s| s.to_string())
324        .collect();
325    let mut push = |items: &[&str]| a.extend(items.iter().map(|s| s.to_string()));
326    let size = format!("{}x{}", o.width, o.height);
327    let fps = o.fps.to_string();
328    push(&[
329        "-f", "rawvideo", "-pix_fmt", "rgba", "-s", &size, "-r", &fps, "-i", "-",
330    ]);
331    if let Some(audio) = &o.audio {
332        let file = audio.file.to_string_lossy().into_owned();
333        if audio.offset >= 0.0 {
334            push(&["-itsoffset", &format!("{:.6}", audio.offset), "-i", &file]);
335        } else {
336            push(&["-ss", &format!("{:.6}", -audio.offset), "-i", &file]);
337        }
338        push(&["-map", "0:v:0", "-map", "1:a:0", "-shortest"]);
339    }
340    match o.format {
341        Format::Mp4 => push(&[
342            "-c:v",
343            "libx264",
344            "-pix_fmt",
345            "yuv420p",
346            "-crf",
347            "18",
348            "-preset",
349            "medium",
350            "-movflags",
351            "+faststart",
352        ]),
353        Format::Webm => push(&[
354            "-c:v",
355            "libvpx-vp9",
356            "-pix_fmt",
357            "yuva420p",
358            "-b:v",
359            "0",
360            "-crf",
361            "30",
362            "-row-mt",
363            "1",
364        ]),
365        Format::ProRes => push(&[
366            "-c:v",
367            "prores_ks",
368            "-profile:v",
369            "4444",
370            "-pix_fmt",
371            "yuva444p10le",
372            "-vendor",
373            "apl0",
374        ]),
375        Format::Png => {}
376    }
377    if o.audio.is_some() {
378        match o.format {
379            Format::Mp4 => push(&["-c:a", "aac", "-b:a", "192k"]),
380            Format::Webm => push(&["-c:a", "libopus", "-b:a", "160k"]),
381            Format::ProRes => push(&["-c:a", "pcm_s16le"]),
382            Format::Png => {}
383        }
384        // Pad audio with silence so `-shortest` never cuts the video.
385        push(&["-af", "apad"]);
386    }
387    a.push(o.out.to_string_lossy().into_owned());
388    a
389}
390
391/// Peak envelope of an audio file for the editor's waveform: `buckets`
392/// values in `0..=1`, decoded with ffmpeg at 8 kHz mono.
393///
394/// # Errors
395///
396/// If ffmpeg is missing or cannot decode the file.
397pub fn waveform(
398    file: &Path,
399    buckets: usize,
400    ffmpeg: Option<&Path>,
401) -> Result<Vec<f32>, ExportError> {
402    let ffmpeg = find_ffmpeg(ffmpeg)?;
403    let out = Command::new(ffmpeg)
404        .args(["-hide_banner", "-loglevel", "error", "-i"])
405        .arg(file)
406        .args(["-ac", "1", "-ar", "8000", "-f", "s16le", "-"])
407        .stderr(Stdio::piped())
408        .output()?;
409    if !out.status.success() {
410        return Err(ExportError::Ffmpeg {
411            status: out.status.to_string(),
412            log: String::from_utf8_lossy(&out.stderr).into_owned(),
413        });
414    }
415    let samples: Vec<i16> = out
416        .stdout
417        .chunks_exact(2)
418        .map(|b| i16::from_le_bytes([b[0], b[1]]))
419        .collect();
420    Ok(peaks(&samples, buckets))
421}
422
423fn peaks(samples: &[i16], buckets: usize) -> Vec<f32> {
424    if samples.is_empty() || buckets == 0 {
425        return vec![0.0; buckets];
426    }
427    (0..buckets)
428        .map(|i| {
429            let a = i * samples.len() / buckets;
430            let b = ((i + 1) * samples.len() / buckets)
431                .max(a + 1)
432                .min(samples.len());
433            let peak = samples[a..b]
434                .iter()
435                .map(|s| s.unsigned_abs())
436                .max()
437                .unwrap_or(0);
438            f32::from(peak) / 32768.0
439        })
440        .collect()
441}
442
443/// Duration of a media file in seconds (via ffmpeg's decoder).
444///
445/// # Errors
446///
447/// If ffmpeg is missing or cannot decode the file.
448pub fn media_duration(file: &Path, ffmpeg: Option<&Path>) -> Result<f64, ExportError> {
449    let ffmpeg = find_ffmpeg(ffmpeg)?;
450    let out = Command::new(ffmpeg)
451        .args(["-hide_banner", "-i"])
452        .arg(file)
453        .args(["-f", "null", "-"])
454        .stderr(Stdio::piped())
455        .output()?;
456    let log = String::from_utf8_lossy(&out.stderr);
457    // "time=00:00:03.21" of the last progress line.
458    let t = log
459        .rsplit("time=")
460        .next()
461        .and_then(|s| s.split_whitespace().next())
462        .and_then(|t| {
463            let mut parts = t.split(':').map(|p| p.parse::<f64>().ok());
464            Some(parts.next()?? * 3600.0 + parts.next()?? * 60.0 + parts.next()??)
465        });
466    t.ok_or(ExportError::Ffmpeg {
467        status: "no duration".into(),
468        log: log.into_owned(),
469    })
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    fn opts(format: Format, out: &str) -> ExportOptions {
477        ExportOptions {
478            out: out.into(),
479            format,
480            width: 64,
481            height: 36,
482            fps: 30,
483            audio: None,
484            ffmpeg: None,
485        }
486    }
487
488    #[test]
489    fn args_for_formats() {
490        let a = ffmpeg_args(&opts(Format::Mp4, "o.mp4")).join(" ");
491        assert!(a.contains("-f rawvideo -pix_fmt rgba -s 64x36 -r 30 -i -"));
492        assert!(a.contains("libx264") && a.contains("yuv420p") && a.ends_with("o.mp4"));
493        assert!(
494            ffmpeg_args(&opts(Format::Webm, "o.webm"))
495                .join(" ")
496                .contains("yuva420p")
497        );
498        assert!(
499            ffmpeg_args(&opts(Format::ProRes, "o.mov"))
500                .join(" ")
501                .contains("prores_ks")
502        );
503        let mut o = opts(Format::Mp4, "o.mp4");
504        o.audio = Some(Audio {
505            file: "a.wav".into(),
506            offset: -1.5,
507        });
508        let a = ffmpeg_args(&o).join(" ");
509        assert!(a.contains("-ss 1.500000 -i a.wav") && a.contains("-c:a aac"));
510    }
511
512    #[test]
513    fn format_from_path() {
514        assert_eq!(Format::from_path(Path::new("x.MP4")), Format::Mp4);
515        assert_eq!(Format::from_path(Path::new("x.mov")), Format::ProRes);
516        assert_eq!(Format::from_path(Path::new("frames")), Format::Png);
517    }
518
519    #[test]
520    fn peaks_downsample() {
521        let s = [0i16, 100, -32768, 5, 7, 8];
522        assert_eq!(peaks(&s, 3), vec![100.0 / 32768.0, 1.0, 8.0 / 32768.0]);
523    }
524
525    #[test]
526    fn encodes_mp4_when_ffmpeg_is_available() {
527        if find_ffmpeg(None).is_err() {
528            eprintln!("SKIPPED: no ffmpeg");
529            return;
530        }
531        let out =
532            std::env::temp_dir().join(format!("oxframe-export-test-{}.mp4", std::process::id()));
533        let mut o = opts(Format::Mp4, out.to_str().unwrap());
534        o.width = 64;
535        let mut ex = Exporter::start(&o).unwrap();
536        for i in 0..30u8 {
537            ex.push(Image {
538                width: 64,
539                height: 36,
540                data: vec![i * 8; 64 * 36 * 4],
541            })
542            .unwrap();
543        }
544        ex.finish().unwrap();
545        let d = media_duration(&out, None).unwrap();
546        assert!((d - 1.0).abs() < 0.1, "duration {d}");
547        let _ = std::fs::remove_file(out);
548    }
549}