1#![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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Format {
33 Mp4,
35 Webm,
37 ProRes,
39 Png,
41}
42
43impl Format {
44 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 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 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#[derive(Debug, Clone)]
77pub struct Audio {
78 pub file: PathBuf,
80 pub offset: f64,
82}
83
84#[derive(Debug, Clone)]
86pub struct ExportOptions {
87 pub out: PathBuf,
89 pub format: Format,
91 pub width: u32,
93 pub height: u32,
95 pub fps: u32,
97 pub audio: Option<Audio>,
99 pub ffmpeg: Option<PathBuf>,
101 pub duration: Option<f64>,
104}
105
106#[derive(Debug)]
108pub enum ExportError {
109 FfmpegNotFound,
111 Io(std::io::Error),
113 Ffmpeg {
115 status: String,
117 log: String,
119 },
120 BadSize {
122 width: u32,
124 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
158pub 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
167pub 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
195pub 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 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 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 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 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 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
334pub 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
390fn 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
403fn 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 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
426pub 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
458pub 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 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
491pub 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
543pub 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 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}