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,
18    loudness, mix, mix_with_loudness, 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    /// Animated GIF that loops, one palette for the whole clip, no audio.
42    Gif,
43}
44
45impl Format {
46    /// From a name or extension (`"mp4"`, `"mov"`…).
47    pub fn parse(s: &str) -> Option<Self> {
48        match s.to_ascii_lowercase().as_str() {
49            "mp4" => Some(Self::Mp4),
50            "webm" => Some(Self::Webm),
51            "prores" | "mov" => Some(Self::ProRes),
52            "png" => Some(Self::Png),
53            "gif" => Some(Self::Gif),
54            _ => None,
55        }
56    }
57
58    /// Guesses from the output path; a path without extension is a PNG
59    /// directory.
60    pub fn from_path(path: &Path) -> Self {
61        path.extension()
62            .and_then(|e| e.to_str())
63            .and_then(Self::parse)
64            .unwrap_or(Self::Png)
65    }
66
67    /// File extension; empty for PNG directories.
68    pub fn extension(self) -> &'static str {
69        match self {
70            Self::Mp4 => "mp4",
71            Self::Webm => "webm",
72            Self::ProRes => "mov",
73            Self::Png => "",
74            Self::Gif => "gif",
75        }
76    }
77
78    /// Whether the file can carry a soundtrack.
79    pub fn has_audio(self) -> bool {
80        matches!(self, Self::Mp4 | Self::Webm | Self::ProRes)
81    }
82}
83
84/// Soundtrack to mux into the video.
85#[derive(Debug, Clone)]
86pub struct Audio {
87    /// Audio file in any format ffmpeg reads.
88    pub file: PathBuf,
89    /// Seconds; positive delays the audio, negative skips into it.
90    pub offset: f64,
91}
92
93/// What to export and where.
94#[derive(Debug, Clone)]
95pub struct ExportOptions {
96    /// Output file, or directory for PNG.
97    pub out: PathBuf,
98    /// Output format.
99    pub format: Format,
100    /// Frame width in pixels.
101    pub width: u32,
102    /// Frame height in pixels.
103    pub height: u32,
104    /// Frames per second.
105    pub fps: u32,
106    /// Optional soundtrack (ignored for PNG and GIF).
107    pub audio: Option<Audio>,
108    /// Explicit ffmpeg binary; otherwise found in `PATH`.
109    pub ffmpeg: Option<PathBuf>,
110    /// Stop after this many seconds. Without it, padding the soundtrack can
111    /// run past the last frame.
112    pub duration: Option<f64>,
113}
114
115/// Why an export failed.
116#[derive(Debug)]
117pub enum ExportError {
118    /// No ffmpeg binary; the message explains how to install it.
119    FfmpegNotFound,
120    /// Writing output or talking to ffmpeg failed.
121    Io(std::io::Error),
122    /// ffmpeg exited with an error.
123    Ffmpeg {
124        /// Exit status.
125        status: String,
126        /// The end of ffmpeg's stderr.
127        log: String,
128    },
129    /// MP4 needs even dimensions.
130    BadSize {
131        /// Requested width.
132        width: u32,
133        /// Requested height.
134        height: u32,
135    },
136}
137
138impl fmt::Display for ExportError {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        match self {
141            Self::FfmpegNotFound => f.write_str(FFMPEG_HELP),
142            Self::Io(e) => write!(f, "{e}"),
143            Self::Ffmpeg { status, log } => write!(f, "ffmpeg failed ({status}):\n{log}"),
144            Self::BadSize { width, height } => write!(
145                f,
146                "{width}x{height}: mp4 (yuv420p) needs even width and height"
147            ),
148        }
149    }
150}
151
152impl std::error::Error for ExportError {
153    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
154        match self {
155            Self::Io(e) => Some(e),
156            _ => None,
157        }
158    }
159}
160
161impl From<std::io::Error> for ExportError {
162    fn from(e: std::io::Error) -> Self {
163        Self::Io(e)
164    }
165}
166
167/// Installation hint shown when ffmpeg is missing.
168pub const FFMPEG_HELP: &str = "\
169ffmpeg was not found in PATH. Install it and try again:
170  Linux:   sudo apt install ffmpeg      (or dnf/pacman install ffmpeg)
171  macOS:   brew install ffmpeg
172  Windows: winget install Gyan.FFmpeg   (then reopen the terminal)
173or set `ffmpeg = \"/path/to/ffmpeg\"` in oxframe.toml / pass --ffmpeg <path>.
174PNG sequences (--format png) do not need ffmpeg.";
175
176/// Finds `ffmpeg`: the explicit path if given, else the first match in
177/// `PATH` (with `.exe` on Windows).
178///
179/// # Errors
180///
181/// [`ExportError::FfmpegNotFound`] if there is no such binary.
182pub fn find_ffmpeg(explicit: Option<&Path>) -> Result<PathBuf, ExportError> {
183    if let Some(p) = explicit {
184        return if p.is_file() {
185            Ok(p.to_path_buf())
186        } else {
187            Err(ExportError::FfmpegNotFound)
188        };
189    }
190    let name = if cfg!(windows) {
191        "ffmpeg.exe"
192    } else {
193        "ffmpeg"
194    };
195    std::env::var_os("PATH")
196        .and_then(|paths| {
197            std::env::split_paths(&paths)
198                .map(|d| d.join(name))
199                .find(|p| p.is_file())
200        })
201        .ok_or(ExportError::FfmpegNotFound)
202}
203
204/// Receives frames in order and writes them out.
205pub struct Exporter {
206    tx: Option<SyncSender<Image>>,
207    worker: Option<JoinHandle<Result<(), ExportError>>>,
208}
209
210enum Sink {
211    Ffmpeg(Child),
212    Png { dir: PathBuf, next: u32 },
213}
214
215impl Exporter {
216    /// Starts ffmpeg (or creates the PNG directory).
217    ///
218    /// # Errors
219    ///
220    /// If ffmpeg is missing or cannot start, the output cannot be created,
221    /// or the size is odd for MP4.
222    pub fn start(opts: &ExportOptions) -> Result<Self, ExportError> {
223        let sink = match opts.format {
224            Format::Png => {
225                std::fs::create_dir_all(&opts.out)?;
226                Sink::Png {
227                    dir: opts.out.clone(),
228                    next: 0,
229                }
230            }
231            _ => {
232                if opts.format == Format::Mp4
233                    && (!opts.width.is_multiple_of(2) || !opts.height.is_multiple_of(2))
234                {
235                    return Err(ExportError::BadSize {
236                        width: opts.width,
237                        height: opts.height,
238                    });
239                }
240                let ffmpeg = find_ffmpeg(opts.ffmpeg.as_deref())?;
241                if let Some(dir) = opts.out.parent().filter(|d| !d.as_os_str().is_empty()) {
242                    std::fs::create_dir_all(dir)?;
243                }
244                let child = Command::new(ffmpeg)
245                    .args(ffmpeg_args(opts))
246                    .stdin(Stdio::piped())
247                    .stdout(Stdio::null())
248                    .stderr(Stdio::piped())
249                    .spawn()?;
250                Sink::Ffmpeg(child)
251            }
252        };
253        // A small bounded queue: rendering runs ahead of encoding by at
254        // most a few frames, keeping memory flat.
255        let (tx, rx) = mpsc::sync_channel::<Image>(4);
256        let worker = std::thread::Builder::new()
257            .name("oxframe-export".into())
258            .spawn(move || write_frames(sink, rx))?;
259        Ok(Self {
260            tx: Some(tx),
261            worker: Some(worker),
262        })
263    }
264
265    /// Queues a frame. Fails early if the writer has stopped (e.g. ffmpeg
266    /// exited).
267    ///
268    /// # Errors
269    ///
270    /// If the writer stopped; [`finish`](Self::finish) then reports why.
271    pub fn push(&mut self, frame: Image) -> Result<(), ExportError> {
272        let tx = self.tx.as_ref().expect("push after finish");
273        if tx.send(frame).is_err() {
274            return self.finish();
275        }
276        Ok(())
277    }
278
279    /// Flushes, waits for ffmpeg and reports its errors.
280    ///
281    /// # Errors
282    ///
283    /// If writing failed or ffmpeg exited with an error (its log is included).
284    pub fn finish(&mut self) -> Result<(), ExportError> {
285        drop(self.tx.take());
286        match self.worker.take() {
287            Some(w) => w.join().unwrap_or_else(|_| {
288                Err(ExportError::Io(std::io::Error::other(
289                    "export thread panicked",
290                )))
291            }),
292            None => Ok(()),
293        }
294    }
295}
296
297impl Drop for Exporter {
298    fn drop(&mut self) {
299        let _ = self.finish();
300    }
301}
302
303fn write_frames(sink: Sink, rx: Receiver<Image>) -> Result<(), ExportError> {
304    match sink {
305        Sink::Png { dir, mut next } => {
306            for frame in rx {
307                std::fs::write(dir.join(format!("{next:06}.png")), frame.encode_png())?;
308                next += 1;
309            }
310            Ok(())
311        }
312        Sink::Ffmpeg(mut child) => {
313            let mut stdin = child.stdin.take().expect("piped stdin");
314            let mut stderr = child.stderr.take().expect("piped stderr");
315            // Drain stderr concurrently so ffmpeg never blocks on it.
316            let log = std::thread::spawn(move || {
317                let mut s = String::new();
318                let _ = stderr.read_to_string(&mut s);
319                s
320            });
321            let mut write_err = None;
322            for frame in rx {
323                if let Err(e) = stdin.write_all(&frame.data) {
324                    write_err = Some(e);
325                    break;
326                }
327            }
328            drop(stdin);
329            let status = child.wait()?;
330            let log = log.join().unwrap_or_default();
331            if !status.success() || write_err.is_some() {
332                let tail: Vec<&str> = log.lines().rev().take(15).collect();
333                return Err(ExportError::Ffmpeg {
334                    status: status.to_string(),
335                    log: tail.into_iter().rev().collect::<Vec<_>>().join("\n"),
336                });
337            }
338            Ok(())
339        }
340    }
341}
342
343/// Command line for ffmpeg (without the program name).
344pub fn ffmpeg_args(o: &ExportOptions) -> Vec<String> {
345    let mut a: Vec<String> = ["-hide_banner", "-loglevel", "error", "-y"]
346        .iter()
347        .map(|s| s.to_string())
348        .collect();
349    let mut push = |items: &[&str]| a.extend(items.iter().map(|s| s.to_string()));
350    let size = format!("{}x{}", o.width, o.height);
351    let fps = o.fps.to_string();
352    push(&[
353        "-f", "rawvideo", "-pix_fmt", "rgba", "-s", &size, "-r", &fps, "-i", "-",
354    ]);
355    let audio = o.audio.as_ref().filter(|_| o.format.has_audio());
356    audio_input(&mut a, audio);
357    let mut push = |items: &[&str]| a.extend(items.iter().map(|s| s.to_string()));
358    match o.format {
359        Format::Mp4 => push(&[
360            "-c:v",
361            "libx264",
362            "-pix_fmt",
363            "yuv420p",
364            "-crf",
365            "18",
366            "-preset",
367            "medium",
368            "-movflags",
369            "+faststart",
370        ]),
371        Format::Webm => push(&[
372            "-c:v",
373            "libvpx-vp9",
374            "-pix_fmt",
375            "yuva420p",
376            "-b:v",
377            "0",
378            "-crf",
379            "30",
380            "-row-mt",
381            "1",
382        ]),
383        Format::ProRes => push(&[
384            "-c:v",
385            "prores_ks",
386            "-profile:v",
387            "4444",
388            "-pix_fmt",
389            "yuva444p10le",
390            "-vendor",
391            "apl0",
392        ]),
393        // One palette from every frame, then each frame mapped to it with an
394        // ordered dither: unlike error diffusion it does not shimmer between
395        // frames, and only the changed rectangle is stored.
396        Format::Gif => push(&[
397            "-vf",
398            "split[a][b];[a]palettegen=stats_mode=full[p];\
399             [b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle",
400            "-loop",
401            "0",
402        ]),
403        Format::Png => {}
404    }
405    audio_output(&mut a, audio.is_some(), o.format, o.duration);
406    a.push(o.out.to_string_lossy().into_owned());
407    a
408}
409
410/// The soundtrack as input 1, mapped next to the video of input 0.
411fn audio_input(a: &mut Vec<String>, audio: Option<&Audio>) {
412    let Some(audio) = audio else { return };
413    let mut push = |items: &[&str]| a.extend(items.iter().map(|s| s.to_string()));
414    let file = audio.file.to_string_lossy().into_owned();
415    if audio.offset >= 0.0 {
416        push(&["-itsoffset", &format!("{:.6}", audio.offset), "-i", &file]);
417    } else {
418        push(&["-ss", &format!("{:.6}", -audio.offset), "-i", &file]);
419    }
420    push(&["-map", "0:v:0", "-map", "1:a:0", "-shortest"]);
421}
422
423/// The audio codec for `format`, padded to the video's length.
424fn audio_output(a: &mut Vec<String>, audio: bool, format: Format, duration: Option<f64>) {
425    if !audio {
426        return;
427    }
428    let mut push = |items: &[&str]| a.extend(items.iter().map(|s| s.to_string()));
429    match format {
430        Format::Mp4 => push(&["-c:a", "aac", "-b:a", "192k"]),
431        Format::Webm => push(&["-c:a", "libopus", "-b:a", "160k"]),
432        Format::ProRes => push(&["-c:a", "pcm_s16le"]),
433        Format::Png | Format::Gif => {}
434    }
435    // Pad audio with silence so `-shortest` never cuts the video.
436    // `whole_dur` stops the pad at the last frame; an open-ended pad
437    // ignores `-shortest` and the file runs on.
438    if let Some(seconds) = duration {
439        let filter = format!("apad=whole_dur={seconds:.6}");
440        push(&["-af", &filter, "-t", &format!("{seconds:.6}")]);
441    } else {
442        push(&["-af", "apad"]);
443    }
444}
445
446/// Command line for [`concat()`] (without the program name): the segments
447/// listed in `list` joined without re-encoding, plus the soundtrack.
448pub fn concat_args(list: &Path, o: &ExportOptions) -> Vec<String> {
449    let mut a: Vec<String> = [
450        "-hide_banner",
451        "-loglevel",
452        "error",
453        "-y",
454        "-f",
455        "concat",
456        "-safe",
457        "0",
458        "-i",
459    ]
460    .iter()
461    .map(|s| s.to_string())
462    .collect();
463    a.push(list.to_string_lossy().into_owned());
464    if o.audio.is_some() {
465        audio_input(&mut a, o.audio.as_ref());
466    } else {
467        a.extend(["-map", "0:v:0"].map(String::from));
468    }
469    a.extend(["-c:v", "copy"].map(String::from));
470    if o.format == Format::Mp4 {
471        a.extend(["-movflags", "+faststart"].map(String::from));
472    }
473    audio_output(&mut a, o.audio.is_some(), o.format, o.duration);
474    a.push(o.out.to_string_lossy().into_owned());
475    a
476}
477
478/// Joins video files encoded with the same settings (`segments`, in
479/// order) into `opts.out` without re-encoding them, and adds
480/// `opts.audio`. Used by parallel rendering: each segment is encoded on
481/// its own, and the result does not depend on how many ran at once.
482///
483/// # Errors
484///
485/// If ffmpeg is missing or fails (its log is included).
486pub fn concat(segments: &[PathBuf], opts: &ExportOptions) -> Result<(), ExportError> {
487    let ffmpeg = find_ffmpeg(opts.ffmpeg.as_deref())?;
488    let list = opts.out.with_extension("segments.txt");
489    let mut text = String::new();
490    for seg in segments {
491        let path = std::fs::canonicalize(seg)?;
492        // The concat list quotes paths with single quotes.
493        let quoted = path.to_string_lossy().replace('\'', "'\\''");
494        text.push_str(&format!("file '{quoted}'\n"));
495    }
496    std::fs::write(&list, text)?;
497    let output = Command::new(ffmpeg).args(concat_args(&list, opts)).output();
498    let _ = std::fs::remove_file(&list);
499    let output = output?;
500    if !output.status.success() {
501        let log = String::from_utf8_lossy(&output.stderr);
502        let tail: Vec<&str> = log.lines().rev().take(15).collect();
503        return Err(ExportError::Ffmpeg {
504            status: output.status.to_string(),
505            log: tail.into_iter().rev().collect::<Vec<_>>().join("\n"),
506        });
507    }
508    Ok(())
509}
510
511/// Peak envelope of an audio file for the editor's waveform: `buckets`
512/// values in `0..=1`, decoded with ffmpeg at 8 kHz mono.
513///
514/// # Errors
515///
516/// If ffmpeg is missing or cannot decode the file.
517pub fn waveform(
518    file: &Path,
519    buckets: usize,
520    ffmpeg: Option<&Path>,
521) -> Result<Vec<f32>, ExportError> {
522    let ffmpeg = find_ffmpeg(ffmpeg)?;
523    let out = Command::new(ffmpeg)
524        .args(["-hide_banner", "-loglevel", "error", "-i"])
525        .arg(file)
526        .args(["-ac", "1", "-ar", "8000", "-f", "s16le", "-"])
527        .stderr(Stdio::piped())
528        .output()?;
529    if !out.status.success() {
530        return Err(ExportError::Ffmpeg {
531            status: out.status.to_string(),
532            log: String::from_utf8_lossy(&out.stderr).into_owned(),
533        });
534    }
535    let samples: Vec<i16> = out
536        .stdout
537        .chunks_exact(2)
538        .map(|b| i16::from_le_bytes([b[0], b[1]]))
539        .collect();
540    Ok(peaks(&samples, buckets))
541}
542
543fn peaks(samples: &[i16], buckets: usize) -> Vec<f32> {
544    if samples.is_empty() || buckets == 0 {
545        return vec![0.0; buckets];
546    }
547    (0..buckets)
548        .map(|i| {
549            let a = i * samples.len() / buckets;
550            let b = ((i + 1) * samples.len() / buckets)
551                .max(a + 1)
552                .min(samples.len());
553            let peak = samples[a..b]
554                .iter()
555                .map(|s| s.unsigned_abs())
556                .max()
557                .unwrap_or(0);
558            f32::from(peak) / 32768.0
559        })
560        .collect()
561}
562
563/// Duration of a media file in seconds (via ffmpeg's decoder).
564///
565/// # Errors
566///
567/// If ffmpeg is missing or cannot decode the file.
568pub fn media_duration(file: &Path, ffmpeg: Option<&Path>) -> Result<f64, ExportError> {
569    let ffmpeg = find_ffmpeg(ffmpeg)?;
570    let out = Command::new(ffmpeg)
571        .args(["-hide_banner", "-i"])
572        .arg(file)
573        .args(["-f", "null", "-"])
574        .stderr(Stdio::piped())
575        .output()?;
576    let log = String::from_utf8_lossy(&out.stderr);
577    // "time=00:00:03.21" of the last progress line.
578    let t = log
579        .rsplit("time=")
580        .next()
581        .and_then(|s| s.split_whitespace().next())
582        .and_then(|t| {
583            let mut parts = t.split(':').map(|p| p.parse::<f64>().ok());
584            Some(parts.next()?? * 3600.0 + parts.next()?? * 60.0 + parts.next()??)
585        });
586    t.ok_or(ExportError::Ffmpeg {
587        status: "no duration".into(),
588        log: log.into_owned(),
589    })
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    fn opts(format: Format, out: &str) -> ExportOptions {
597        ExportOptions {
598            out: out.into(),
599            format,
600            width: 64,
601            height: 36,
602            fps: 30,
603            audio: None,
604            ffmpeg: None,
605            duration: None,
606        }
607    }
608
609    #[test]
610    fn args_for_formats() {
611        let a = ffmpeg_args(&opts(Format::Mp4, "o.mp4")).join(" ");
612        assert!(a.contains("-f rawvideo -pix_fmt rgba -s 64x36 -r 30 -i -"));
613        assert!(a.contains("libx264") && a.contains("yuv420p") && a.ends_with("o.mp4"));
614        assert!(
615            ffmpeg_args(&opts(Format::Webm, "o.webm"))
616                .join(" ")
617                .contains("yuva420p")
618        );
619        assert!(
620            ffmpeg_args(&opts(Format::ProRes, "o.mov"))
621                .join(" ")
622                .contains("prores_ks")
623        );
624        let mut o = opts(Format::Mp4, "o.mp4");
625        o.audio = Some(Audio {
626            file: "a.wav".into(),
627            offset: -1.5,
628        });
629        let a = ffmpeg_args(&o).join(" ");
630        assert!(a.contains("-ss 1.500000 -i a.wav") && a.contains("-c:a aac"));
631        // A GIF has no sound, even when given one.
632        o.format = Format::Gif;
633        o.out = "o.gif".into();
634        let a = ffmpeg_args(&o).join(" ");
635        assert!(a.contains("palettegen") && a.contains("-loop 0") && a.ends_with("o.gif"));
636        assert!(!a.contains("a.wav") && !a.contains("-c:a"));
637    }
638
639    #[test]
640    fn format_from_path() {
641        assert_eq!(Format::from_path(Path::new("x.MP4")), Format::Mp4);
642        assert_eq!(Format::from_path(Path::new("x.mov")), Format::ProRes);
643        assert_eq!(Format::from_path(Path::new("frames")), Format::Png);
644        assert_eq!(Format::from_path(Path::new("x.gif")), Format::Gif);
645    }
646
647    #[test]
648    fn encodes_gif_when_ffmpeg_is_available() {
649        if find_ffmpeg(None).is_err() {
650            eprintln!("SKIPPED: no ffmpeg");
651            return;
652        }
653        let out =
654            std::env::temp_dir().join(format!("oxframe-export-test-{}.gif", std::process::id()));
655        let mut o = opts(Format::Gif, out.to_str().unwrap());
656        o.width = 63; // odd sizes are fine for GIF
657        o.fps = 15;
658        let mut ex = Exporter::start(&o).unwrap();
659        for i in 0..15u8 {
660            ex.push(Image {
661                width: 63,
662                height: 36,
663                data: vec![i * 16; 63 * 36 * 4],
664            })
665            .unwrap();
666        }
667        ex.finish().unwrap();
668        let bytes = std::fs::read(&out).unwrap();
669        assert!(bytes.starts_with(b"GIF89a"));
670        let d = media_duration(&out, None).unwrap();
671        assert!((d - 1.0).abs() < 0.15, "duration {d}");
672        let _ = std::fs::remove_file(out);
673    }
674
675    #[test]
676    fn peaks_downsample() {
677        let s = [0i16, 100, -32768, 5, 7, 8];
678        assert_eq!(peaks(&s, 3), vec![100.0 / 32768.0, 1.0, 8.0 / 32768.0]);
679    }
680
681    #[test]
682    fn encodes_mp4_when_ffmpeg_is_available() {
683        if find_ffmpeg(None).is_err() {
684            eprintln!("SKIPPED: no ffmpeg");
685            return;
686        }
687        let out =
688            std::env::temp_dir().join(format!("oxframe-export-test-{}.mp4", std::process::id()));
689        let mut o = opts(Format::Mp4, out.to_str().unwrap());
690        o.width = 64;
691        let mut ex = Exporter::start(&o).unwrap();
692        for i in 0..30u8 {
693            ex.push(Image {
694                width: 64,
695                height: 36,
696                data: vec![i * 8; 64 * 36 * 4],
697            })
698            .unwrap();
699        }
700        ex.finish().unwrap();
701        let d = media_duration(&out, None).unwrap();
702        assert!((d - 1.0).abs() < 0.1, "duration {d}");
703        let _ = std::fs::remove_file(out);
704    }
705}