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