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