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    audio_input(&mut a, o.audio.as_ref());
347    let mut push = |items: &[&str]| a.extend(items.iter().map(|s| s.to_string()));
348    match o.format {
349        Format::Mp4 => push(&[
350            "-c:v",
351            "libx264",
352            "-pix_fmt",
353            "yuv420p",
354            "-crf",
355            "18",
356            "-preset",
357            "medium",
358            "-movflags",
359            "+faststart",
360        ]),
361        Format::Webm => push(&[
362            "-c:v",
363            "libvpx-vp9",
364            "-pix_fmt",
365            "yuva420p",
366            "-b:v",
367            "0",
368            "-crf",
369            "30",
370            "-row-mt",
371            "1",
372        ]),
373        Format::ProRes => push(&[
374            "-c:v",
375            "prores_ks",
376            "-profile:v",
377            "4444",
378            "-pix_fmt",
379            "yuva444p10le",
380            "-vendor",
381            "apl0",
382        ]),
383        Format::Png => {}
384    }
385    audio_output(&mut a, o.audio.is_some(), o.format, o.duration);
386    a.push(o.out.to_string_lossy().into_owned());
387    a
388}
389
390/// The soundtrack as input 1, mapped next to the video of input 0.
391fn audio_input(a: &mut Vec<String>, audio: Option<&Audio>) {
392    let Some(audio) = audio else { return };
393    let mut push = |items: &[&str]| a.extend(items.iter().map(|s| s.to_string()));
394    let file = audio.file.to_string_lossy().into_owned();
395    if audio.offset >= 0.0 {
396        push(&["-itsoffset", &format!("{:.6}", audio.offset), "-i", &file]);
397    } else {
398        push(&["-ss", &format!("{:.6}", -audio.offset), "-i", &file]);
399    }
400    push(&["-map", "0:v:0", "-map", "1:a:0", "-shortest"]);
401}
402
403/// The audio codec for `format`, padded to the video's length.
404fn audio_output(a: &mut Vec<String>, audio: bool, format: Format, duration: Option<f64>) {
405    if !audio {
406        return;
407    }
408    let mut push = |items: &[&str]| a.extend(items.iter().map(|s| s.to_string()));
409    match format {
410        Format::Mp4 => push(&["-c:a", "aac", "-b:a", "192k"]),
411        Format::Webm => push(&["-c:a", "libopus", "-b:a", "160k"]),
412        Format::ProRes => push(&["-c:a", "pcm_s16le"]),
413        Format::Png => {}
414    }
415    // Pad audio with silence so `-shortest` never cuts the video.
416    // `whole_dur` stops the pad at the last frame; an open-ended pad
417    // ignores `-shortest` and the file runs on.
418    if let Some(seconds) = duration {
419        let filter = format!("apad=whole_dur={seconds:.6}");
420        push(&["-af", &filter, "-t", &format!("{seconds:.6}")]);
421    } else {
422        push(&["-af", "apad"]);
423    }
424}
425
426/// Command line for [`concat()`] (without the program name): the segments
427/// listed in `list` joined without re-encoding, plus the soundtrack.
428pub fn concat_args(list: &Path, o: &ExportOptions) -> Vec<String> {
429    let mut a: Vec<String> = [
430        "-hide_banner",
431        "-loglevel",
432        "error",
433        "-y",
434        "-f",
435        "concat",
436        "-safe",
437        "0",
438        "-i",
439    ]
440    .iter()
441    .map(|s| s.to_string())
442    .collect();
443    a.push(list.to_string_lossy().into_owned());
444    if o.audio.is_some() {
445        audio_input(&mut a, o.audio.as_ref());
446    } else {
447        a.extend(["-map", "0:v:0"].map(String::from));
448    }
449    a.extend(["-c:v", "copy"].map(String::from));
450    if o.format == Format::Mp4 {
451        a.extend(["-movflags", "+faststart"].map(String::from));
452    }
453    audio_output(&mut a, o.audio.is_some(), o.format, o.duration);
454    a.push(o.out.to_string_lossy().into_owned());
455    a
456}
457
458/// Joins video files encoded with the same settings (`segments`, in
459/// order) into `opts.out` without re-encoding them, and adds
460/// `opts.audio`. Used by parallel rendering: each segment is encoded on
461/// its own, and the result does not depend on how many ran at once.
462///
463/// # Errors
464///
465/// If ffmpeg is missing or fails (its log is included).
466pub fn concat(segments: &[PathBuf], opts: &ExportOptions) -> Result<(), ExportError> {
467    let ffmpeg = find_ffmpeg(opts.ffmpeg.as_deref())?;
468    let list = opts.out.with_extension("segments.txt");
469    let mut text = String::new();
470    for seg in segments {
471        let path = std::fs::canonicalize(seg)?;
472        // The concat list quotes paths with single quotes.
473        let quoted = path.to_string_lossy().replace('\'', "'\\''");
474        text.push_str(&format!("file '{quoted}'\n"));
475    }
476    std::fs::write(&list, text)?;
477    let output = Command::new(ffmpeg).args(concat_args(&list, opts)).output();
478    let _ = std::fs::remove_file(&list);
479    let output = output?;
480    if !output.status.success() {
481        let log = String::from_utf8_lossy(&output.stderr);
482        let tail: Vec<&str> = log.lines().rev().take(15).collect();
483        return Err(ExportError::Ffmpeg {
484            status: output.status.to_string(),
485            log: tail.into_iter().rev().collect::<Vec<_>>().join("\n"),
486        });
487    }
488    Ok(())
489}
490
491/// Peak envelope of an audio file for the editor's waveform: `buckets`
492/// values in `0..=1`, decoded with ffmpeg at 8 kHz mono.
493///
494/// # Errors
495///
496/// If ffmpeg is missing or cannot decode the file.
497pub fn waveform(
498    file: &Path,
499    buckets: usize,
500    ffmpeg: Option<&Path>,
501) -> Result<Vec<f32>, ExportError> {
502    let ffmpeg = find_ffmpeg(ffmpeg)?;
503    let out = Command::new(ffmpeg)
504        .args(["-hide_banner", "-loglevel", "error", "-i"])
505        .arg(file)
506        .args(["-ac", "1", "-ar", "8000", "-f", "s16le", "-"])
507        .stderr(Stdio::piped())
508        .output()?;
509    if !out.status.success() {
510        return Err(ExportError::Ffmpeg {
511            status: out.status.to_string(),
512            log: String::from_utf8_lossy(&out.stderr).into_owned(),
513        });
514    }
515    let samples: Vec<i16> = out
516        .stdout
517        .chunks_exact(2)
518        .map(|b| i16::from_le_bytes([b[0], b[1]]))
519        .collect();
520    Ok(peaks(&samples, buckets))
521}
522
523fn peaks(samples: &[i16], buckets: usize) -> Vec<f32> {
524    if samples.is_empty() || buckets == 0 {
525        return vec![0.0; buckets];
526    }
527    (0..buckets)
528        .map(|i| {
529            let a = i * samples.len() / buckets;
530            let b = ((i + 1) * samples.len() / buckets)
531                .max(a + 1)
532                .min(samples.len());
533            let peak = samples[a..b]
534                .iter()
535                .map(|s| s.unsigned_abs())
536                .max()
537                .unwrap_or(0);
538            f32::from(peak) / 32768.0
539        })
540        .collect()
541}
542
543/// Duration of a media file in seconds (via ffmpeg's decoder).
544///
545/// # Errors
546///
547/// If ffmpeg is missing or cannot decode the file.
548pub fn media_duration(file: &Path, ffmpeg: Option<&Path>) -> Result<f64, ExportError> {
549    let ffmpeg = find_ffmpeg(ffmpeg)?;
550    let out = Command::new(ffmpeg)
551        .args(["-hide_banner", "-i"])
552        .arg(file)
553        .args(["-f", "null", "-"])
554        .stderr(Stdio::piped())
555        .output()?;
556    let log = String::from_utf8_lossy(&out.stderr);
557    // "time=00:00:03.21" of the last progress line.
558    let t = log
559        .rsplit("time=")
560        .next()
561        .and_then(|s| s.split_whitespace().next())
562        .and_then(|t| {
563            let mut parts = t.split(':').map(|p| p.parse::<f64>().ok());
564            Some(parts.next()?? * 3600.0 + parts.next()?? * 60.0 + parts.next()??)
565        });
566    t.ok_or(ExportError::Ffmpeg {
567        status: "no duration".into(),
568        log: log.into_owned(),
569    })
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    fn opts(format: Format, out: &str) -> ExportOptions {
577        ExportOptions {
578            out: out.into(),
579            format,
580            width: 64,
581            height: 36,
582            fps: 30,
583            audio: None,
584            ffmpeg: None,
585            duration: None,
586        }
587    }
588
589    #[test]
590    fn args_for_formats() {
591        let a = ffmpeg_args(&opts(Format::Mp4, "o.mp4")).join(" ");
592        assert!(a.contains("-f rawvideo -pix_fmt rgba -s 64x36 -r 30 -i -"));
593        assert!(a.contains("libx264") && a.contains("yuv420p") && a.ends_with("o.mp4"));
594        assert!(
595            ffmpeg_args(&opts(Format::Webm, "o.webm"))
596                .join(" ")
597                .contains("yuva420p")
598        );
599        assert!(
600            ffmpeg_args(&opts(Format::ProRes, "o.mov"))
601                .join(" ")
602                .contains("prores_ks")
603        );
604        let mut o = opts(Format::Mp4, "o.mp4");
605        o.audio = Some(Audio {
606            file: "a.wav".into(),
607            offset: -1.5,
608        });
609        let a = ffmpeg_args(&o).join(" ");
610        assert!(a.contains("-ss 1.500000 -i a.wav") && a.contains("-c:a aac"));
611    }
612
613    #[test]
614    fn format_from_path() {
615        assert_eq!(Format::from_path(Path::new("x.MP4")), Format::Mp4);
616        assert_eq!(Format::from_path(Path::new("x.mov")), Format::ProRes);
617        assert_eq!(Format::from_path(Path::new("frames")), Format::Png);
618    }
619
620    #[test]
621    fn peaks_downsample() {
622        let s = [0i16, 100, -32768, 5, 7, 8];
623        assert_eq!(peaks(&s, 3), vec![100.0 / 32768.0, 1.0, 8.0 / 32768.0]);
624    }
625
626    #[test]
627    fn encodes_mp4_when_ffmpeg_is_available() {
628        if find_ffmpeg(None).is_err() {
629            eprintln!("SKIPPED: no ffmpeg");
630            return;
631        }
632        let out =
633            std::env::temp_dir().join(format!("oxframe-export-test-{}.mp4", std::process::id()));
634        let mut o = opts(Format::Mp4, out.to_str().unwrap());
635        o.width = 64;
636        let mut ex = Exporter::start(&o).unwrap();
637        for i in 0..30u8 {
638            ex.push(Image {
639                width: 64,
640                height: 36,
641                data: vec![i * 8; 64 * 36 * 4],
642            })
643            .unwrap();
644        }
645        ex.finish().unwrap();
646        let d = media_duration(&out, None).unwrap();
647        assert!((d - 1.0).abs() < 0.1, "duration {d}");
648        let _ = std::fs::remove_file(out);
649    }
650}