1#![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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Format {
33 Mp4,
35 Webm,
37 ProRes,
39 Png,
41 Gif,
43}
44
45impl Format {
46 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 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 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 pub fn has_audio(self) -> bool {
80 matches!(self, Self::Mp4 | Self::Webm | Self::ProRes)
81 }
82}
83
84#[derive(Debug, Clone)]
86pub struct Audio {
87 pub file: PathBuf,
89 pub offset: f64,
91}
92
93#[derive(Debug, Clone)]
95pub struct ExportOptions {
96 pub out: PathBuf,
98 pub format: Format,
100 pub width: u32,
102 pub height: u32,
104 pub fps: u32,
106 pub audio: Option<Audio>,
108 pub ffmpeg: Option<PathBuf>,
110 pub duration: Option<f64>,
113}
114
115#[derive(Debug)]
117pub enum ExportError {
118 FfmpegNotFound,
120 Io(std::io::Error),
122 Ffmpeg {
124 status: String,
126 log: String,
128 },
129 BadSize {
131 width: u32,
133 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
167pub 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
176pub 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
204pub 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 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 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 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 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 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
343pub 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 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
410fn 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
423fn 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 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
446pub 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
478pub 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 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
511pub 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
563pub 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 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 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; 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}