1#![warn(missing_docs)]
13
14use std::fmt;
15use std::io::{Read, Write};
16use std::path::{Path, PathBuf};
17use std::process::{Child, Command, Stdio};
18use std::sync::mpsc::{self, Receiver, SyncSender};
19use std::thread::JoinHandle;
20
21pub use oxframe_core::Image;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Format {
26 Mp4,
28 Webm,
30 ProRes,
32 Png,
34}
35
36impl Format {
37 pub fn parse(s: &str) -> Option<Self> {
39 match s.to_ascii_lowercase().as_str() {
40 "mp4" => Some(Self::Mp4),
41 "webm" => Some(Self::Webm),
42 "prores" | "mov" => Some(Self::ProRes),
43 "png" => Some(Self::Png),
44 _ => None,
45 }
46 }
47
48 pub fn from_path(path: &Path) -> Self {
51 path.extension()
52 .and_then(|e| e.to_str())
53 .and_then(Self::parse)
54 .unwrap_or(Self::Png)
55 }
56
57 pub fn extension(self) -> &'static str {
59 match self {
60 Self::Mp4 => "mp4",
61 Self::Webm => "webm",
62 Self::ProRes => "mov",
63 Self::Png => "",
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
70pub struct Audio {
71 pub file: PathBuf,
73 pub offset: f64,
75}
76
77#[derive(Debug, Clone)]
79pub struct ExportOptions {
80 pub out: PathBuf,
82 pub format: Format,
84 pub width: u32,
86 pub height: u32,
88 pub fps: u32,
90 pub audio: Option<Audio>,
92 pub ffmpeg: Option<PathBuf>,
94}
95
96#[derive(Debug)]
98pub enum ExportError {
99 FfmpegNotFound,
101 Io(std::io::Error),
103 Ffmpeg {
105 status: String,
107 log: String,
109 },
110 BadSize {
112 width: u32,
114 height: u32,
116 },
117}
118
119impl fmt::Display for ExportError {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 match self {
122 Self::FfmpegNotFound => f.write_str(FFMPEG_HELP),
123 Self::Io(e) => write!(f, "{e}"),
124 Self::Ffmpeg { status, log } => write!(f, "ffmpeg failed ({status}):\n{log}"),
125 Self::BadSize { width, height } => write!(
126 f,
127 "{width}x{height}: mp4 (yuv420p) needs even width and height"
128 ),
129 }
130 }
131}
132
133impl std::error::Error for ExportError {
134 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
135 match self {
136 Self::Io(e) => Some(e),
137 _ => None,
138 }
139 }
140}
141
142impl From<std::io::Error> for ExportError {
143 fn from(e: std::io::Error) -> Self {
144 Self::Io(e)
145 }
146}
147
148pub const FFMPEG_HELP: &str = "\
150ffmpeg was not found in PATH. Install it and try again:
151 Linux: sudo apt install ffmpeg (or dnf/pacman install ffmpeg)
152 macOS: brew install ffmpeg
153 Windows: winget install Gyan.FFmpeg (then reopen the terminal)
154or set `ffmpeg = \"/path/to/ffmpeg\"` in oxframe.toml / pass --ffmpeg <path>.
155PNG sequences (--format png) do not need ffmpeg.";
156
157pub fn find_ffmpeg(explicit: Option<&Path>) -> Result<PathBuf, ExportError> {
164 if let Some(p) = explicit {
165 return if p.is_file() {
166 Ok(p.to_path_buf())
167 } else {
168 Err(ExportError::FfmpegNotFound)
169 };
170 }
171 let name = if cfg!(windows) {
172 "ffmpeg.exe"
173 } else {
174 "ffmpeg"
175 };
176 std::env::var_os("PATH")
177 .and_then(|paths| {
178 std::env::split_paths(&paths)
179 .map(|d| d.join(name))
180 .find(|p| p.is_file())
181 })
182 .ok_or(ExportError::FfmpegNotFound)
183}
184
185pub struct Exporter {
187 tx: Option<SyncSender<Image>>,
188 worker: Option<JoinHandle<Result<(), ExportError>>>,
189}
190
191enum Sink {
192 Ffmpeg(Child),
193 Png { dir: PathBuf, next: u32 },
194}
195
196impl Exporter {
197 pub fn start(opts: &ExportOptions) -> Result<Self, ExportError> {
204 let sink = match opts.format {
205 Format::Png => {
206 std::fs::create_dir_all(&opts.out)?;
207 Sink::Png {
208 dir: opts.out.clone(),
209 next: 0,
210 }
211 }
212 _ => {
213 if opts.format == Format::Mp4
214 && (!opts.width.is_multiple_of(2) || !opts.height.is_multiple_of(2))
215 {
216 return Err(ExportError::BadSize {
217 width: opts.width,
218 height: opts.height,
219 });
220 }
221 let ffmpeg = find_ffmpeg(opts.ffmpeg.as_deref())?;
222 if let Some(dir) = opts.out.parent().filter(|d| !d.as_os_str().is_empty()) {
223 std::fs::create_dir_all(dir)?;
224 }
225 let child = Command::new(ffmpeg)
226 .args(ffmpeg_args(opts))
227 .stdin(Stdio::piped())
228 .stdout(Stdio::null())
229 .stderr(Stdio::piped())
230 .spawn()?;
231 Sink::Ffmpeg(child)
232 }
233 };
234 let (tx, rx) = mpsc::sync_channel::<Image>(4);
237 let worker = std::thread::Builder::new()
238 .name("oxframe-export".into())
239 .spawn(move || write_frames(sink, rx))?;
240 Ok(Self {
241 tx: Some(tx),
242 worker: Some(worker),
243 })
244 }
245
246 pub fn push(&mut self, frame: Image) -> Result<(), ExportError> {
253 let tx = self.tx.as_ref().expect("push after finish");
254 if tx.send(frame).is_err() {
255 return self.finish();
256 }
257 Ok(())
258 }
259
260 pub fn finish(&mut self) -> Result<(), ExportError> {
266 drop(self.tx.take());
267 match self.worker.take() {
268 Some(w) => w.join().unwrap_or_else(|_| {
269 Err(ExportError::Io(std::io::Error::other(
270 "export thread panicked",
271 )))
272 }),
273 None => Ok(()),
274 }
275 }
276}
277
278impl Drop for Exporter {
279 fn drop(&mut self) {
280 let _ = self.finish();
281 }
282}
283
284fn write_frames(sink: Sink, rx: Receiver<Image>) -> Result<(), ExportError> {
285 match sink {
286 Sink::Png { dir, mut next } => {
287 for frame in rx {
288 std::fs::write(dir.join(format!("{next:06}.png")), frame.encode_png())?;
289 next += 1;
290 }
291 Ok(())
292 }
293 Sink::Ffmpeg(mut child) => {
294 let mut stdin = child.stdin.take().expect("piped stdin");
295 let mut stderr = child.stderr.take().expect("piped stderr");
296 let log = std::thread::spawn(move || {
298 let mut s = String::new();
299 let _ = stderr.read_to_string(&mut s);
300 s
301 });
302 let mut write_err = None;
303 for frame in rx {
304 if let Err(e) = stdin.write_all(&frame.data) {
305 write_err = Some(e);
306 break;
307 }
308 }
309 drop(stdin);
310 let status = child.wait()?;
311 let log = log.join().unwrap_or_default();
312 if !status.success() || write_err.is_some() {
313 let tail: Vec<&str> = log.lines().rev().take(15).collect();
314 return Err(ExportError::Ffmpeg {
315 status: status.to_string(),
316 log: tail.into_iter().rev().collect::<Vec<_>>().join("\n"),
317 });
318 }
319 Ok(())
320 }
321 }
322}
323
324pub fn ffmpeg_args(o: &ExportOptions) -> Vec<String> {
326 let mut a: Vec<String> = ["-hide_banner", "-loglevel", "error", "-y"]
327 .iter()
328 .map(|s| s.to_string())
329 .collect();
330 let mut push = |items: &[&str]| a.extend(items.iter().map(|s| s.to_string()));
331 let size = format!("{}x{}", o.width, o.height);
332 let fps = o.fps.to_string();
333 push(&[
334 "-f", "rawvideo", "-pix_fmt", "rgba", "-s", &size, "-r", &fps, "-i", "-",
335 ]);
336 if let Some(audio) = &o.audio {
337 let file = audio.file.to_string_lossy().into_owned();
338 if audio.offset >= 0.0 {
339 push(&["-itsoffset", &format!("{:.6}", audio.offset), "-i", &file]);
340 } else {
341 push(&["-ss", &format!("{:.6}", -audio.offset), "-i", &file]);
342 }
343 push(&["-map", "0:v:0", "-map", "1:a:0", "-shortest"]);
344 }
345 match o.format {
346 Format::Mp4 => push(&[
347 "-c:v",
348 "libx264",
349 "-pix_fmt",
350 "yuv420p",
351 "-crf",
352 "18",
353 "-preset",
354 "medium",
355 "-movflags",
356 "+faststart",
357 ]),
358 Format::Webm => push(&[
359 "-c:v",
360 "libvpx-vp9",
361 "-pix_fmt",
362 "yuva420p",
363 "-b:v",
364 "0",
365 "-crf",
366 "30",
367 "-row-mt",
368 "1",
369 ]),
370 Format::ProRes => push(&[
371 "-c:v",
372 "prores_ks",
373 "-profile:v",
374 "4444",
375 "-pix_fmt",
376 "yuva444p10le",
377 "-vendor",
378 "apl0",
379 ]),
380 Format::Png => {}
381 }
382 if o.audio.is_some() {
383 match o.format {
384 Format::Mp4 => push(&["-c:a", "aac", "-b:a", "192k"]),
385 Format::Webm => push(&["-c:a", "libopus", "-b:a", "160k"]),
386 Format::ProRes => push(&["-c:a", "pcm_s16le"]),
387 Format::Png => {}
388 }
389 push(&["-af", "apad"]);
391 }
392 a.push(o.out.to_string_lossy().into_owned());
393 a
394}
395
396pub fn waveform(
403 file: &Path,
404 buckets: usize,
405 ffmpeg: Option<&Path>,
406) -> Result<Vec<f32>, ExportError> {
407 let ffmpeg = find_ffmpeg(ffmpeg)?;
408 let out = Command::new(ffmpeg)
409 .args(["-hide_banner", "-loglevel", "error", "-i"])
410 .arg(file)
411 .args(["-ac", "1", "-ar", "8000", "-f", "s16le", "-"])
412 .stderr(Stdio::piped())
413 .output()?;
414 if !out.status.success() {
415 return Err(ExportError::Ffmpeg {
416 status: out.status.to_string(),
417 log: String::from_utf8_lossy(&out.stderr).into_owned(),
418 });
419 }
420 let samples: Vec<i16> = out
421 .stdout
422 .chunks_exact(2)
423 .map(|b| i16::from_le_bytes([b[0], b[1]]))
424 .collect();
425 Ok(peaks(&samples, buckets))
426}
427
428fn peaks(samples: &[i16], buckets: usize) -> Vec<f32> {
429 if samples.is_empty() || buckets == 0 {
430 return vec![0.0; buckets];
431 }
432 (0..buckets)
433 .map(|i| {
434 let a = i * samples.len() / buckets;
435 let b = ((i + 1) * samples.len() / buckets)
436 .max(a + 1)
437 .min(samples.len());
438 let peak = samples[a..b]
439 .iter()
440 .map(|s| s.unsigned_abs())
441 .max()
442 .unwrap_or(0);
443 f32::from(peak) / 32768.0
444 })
445 .collect()
446}
447
448pub fn media_duration(file: &Path, ffmpeg: Option<&Path>) -> Result<f64, ExportError> {
454 let ffmpeg = find_ffmpeg(ffmpeg)?;
455 let out = Command::new(ffmpeg)
456 .args(["-hide_banner", "-i"])
457 .arg(file)
458 .args(["-f", "null", "-"])
459 .stderr(Stdio::piped())
460 .output()?;
461 let log = String::from_utf8_lossy(&out.stderr);
462 let t = log
464 .rsplit("time=")
465 .next()
466 .and_then(|s| s.split_whitespace().next())
467 .and_then(|t| {
468 let mut parts = t.split(':').map(|p| p.parse::<f64>().ok());
469 Some(parts.next()?? * 3600.0 + parts.next()?? * 60.0 + parts.next()??)
470 });
471 t.ok_or(ExportError::Ffmpeg {
472 status: "no duration".into(),
473 log: log.into_owned(),
474 })
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480
481 fn opts(format: Format, out: &str) -> ExportOptions {
482 ExportOptions {
483 out: out.into(),
484 format,
485 width: 64,
486 height: 36,
487 fps: 30,
488 audio: None,
489 ffmpeg: None,
490 }
491 }
492
493 #[test]
494 fn args_for_formats() {
495 let a = ffmpeg_args(&opts(Format::Mp4, "o.mp4")).join(" ");
496 assert!(a.contains("-f rawvideo -pix_fmt rgba -s 64x36 -r 30 -i -"));
497 assert!(a.contains("libx264") && a.contains("yuv420p") && a.ends_with("o.mp4"));
498 assert!(
499 ffmpeg_args(&opts(Format::Webm, "o.webm"))
500 .join(" ")
501 .contains("yuva420p")
502 );
503 assert!(
504 ffmpeg_args(&opts(Format::ProRes, "o.mov"))
505 .join(" ")
506 .contains("prores_ks")
507 );
508 let mut o = opts(Format::Mp4, "o.mp4");
509 o.audio = Some(Audio {
510 file: "a.wav".into(),
511 offset: -1.5,
512 });
513 let a = ffmpeg_args(&o).join(" ");
514 assert!(a.contains("-ss 1.500000 -i a.wav") && a.contains("-c:a aac"));
515 }
516
517 #[test]
518 fn format_from_path() {
519 assert_eq!(Format::from_path(Path::new("x.MP4")), Format::Mp4);
520 assert_eq!(Format::from_path(Path::new("x.mov")), Format::ProRes);
521 assert_eq!(Format::from_path(Path::new("frames")), Format::Png);
522 }
523
524 #[test]
525 fn peaks_downsample() {
526 let s = [0i16, 100, -32768, 5, 7, 8];
527 assert_eq!(peaks(&s, 3), vec![100.0 / 32768.0, 1.0, 8.0 / 32768.0]);
528 }
529
530 #[test]
531 fn encodes_mp4_when_ffmpeg_is_available() {
532 if find_ffmpeg(None).is_err() {
533 eprintln!("SKIPPED: no ffmpeg");
534 return;
535 }
536 let out =
537 std::env::temp_dir().join(format!("oxframe-export-test-{}.mp4", std::process::id()));
538 let mut o = opts(Format::Mp4, out.to_str().unwrap());
539 o.width = 64;
540 let mut ex = Exporter::start(&o).unwrap();
541 for i in 0..30u8 {
542 ex.push(Image {
543 width: 64,
544 height: 36,
545 data: vec![i * 8; 64 * 36 * 4],
546 })
547 .unwrap();
548 }
549 ex.finish().unwrap();
550 let d = media_duration(&out, None).unwrap();
551 assert!((d - 1.0).abs() < 0.1, "duration {d}");
552 let _ = std::fs::remove_file(out);
553 }
554}