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