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 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 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
413pub 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
465pub 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 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}