1use super::{is_image_path, AudioSource, Frame, VideoSource, SAMPLE_RATE};
18use crate::model::{Asset, AudioStreamInfo, ClipKind};
19use serde_json::Value;
20use std::collections::HashMap;
21use std::io::Read;
22use std::path::PathBuf;
23use std::process::{Child, ChildStdout, Command, Stdio};
24use std::sync::Mutex;
25
26static DIR: Mutex<String> = Mutex::new(String::new());
27
28pub fn set_dir(dir: &str) {
30 *DIR.lock().unwrap() = dir.to_string();
31}
32
33pub fn ffmpeg_exe() -> Option<PathBuf> {
34 find("ffmpeg.exe")
35}
36pub fn ffprobe_exe() -> Option<PathBuf> {
37 find("ffprobe.exe")
38}
39
40fn find(name: &str) -> Option<PathBuf> {
41 let dir = DIR.lock().unwrap().clone();
42 find_exe(name, &dir)
43}
44
45pub(crate) fn find_exe(name: &str, dir: &str) -> Option<PathBuf> {
48 find_all_exe(name, dir).into_iter().next()
49}
50
51pub(crate) fn find_all_exe(name: &str, dir: &str) -> Vec<PathBuf> {
54 let mut candidates: Vec<PathBuf> = Vec::new();
55 if !dir.is_empty() {
56 candidates.push(PathBuf::from(&dir).join(name));
57 }
58 if let Ok(exe) = std::env::current_exe() {
59 if let Some(d) = exe.parent() {
60 candidates.push(d.join(name));
61 candidates.push(d.join("ffmpeg").join(name));
62 }
63 }
64 if let Some(path) = std::env::var_os("PATH") {
65 candidates.extend(std::env::split_paths(&path).map(|p| p.join(name)));
66 }
67 let mut out: Vec<PathBuf> = Vec::new();
68 for c in candidates {
69 if c.is_file() && !out.contains(&c) {
70 out.push(c);
71 }
72 }
73 out
74}
75
76pub fn command(exe: &std::path::Path) -> Command {
78 use std::os::windows::process::CommandExt;
79 let mut c = Command::new(exe);
80 c.creation_flags(0x0800_0000); c
82}
83
84fn str_of<'a>(v: &'a Value, key: &str) -> &'a str {
87 v.get(key).and_then(Value::as_str).unwrap_or("")
88}
89fn f64_of(v: &Value, key: &str) -> f64 {
90 v.get(key).and_then(|x| x.as_f64().or_else(|| x.as_str().and_then(|s| s.parse().ok()))).unwrap_or(0.0)
91}
92fn parse_rate(s: &str) -> f64 {
94 let (n, d) = s.split_once('/').unwrap_or((s, "1"));
95 match (n.trim().parse::<f64>(), d.trim().parse::<f64>()) {
96 (Ok(n), Ok(d)) if d > 0.0 && n.is_finite() => n / d,
97 _ => 0.0,
98 }
99}
100
101pub fn probe(path: &str) -> Result<Asset, String> {
102 let exe = ffprobe_exe().ok_or("ffprobe.exe not found")?;
103 let out = command(&exe)
104 .args(["-v", "error", "-print_format", "json", "-show_streams", "-show_format", path])
105 .stdin(Stdio::null())
106 .output()
107 .map_err(|e| format!("ffprobe: {e}"))?;
108 if !out.status.success() {
109 let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
110 return Err(if err.is_empty() { format!("ffprobe failed ({})", out.status) } else { err });
111 }
112 let v: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("ffprobe json: {e}"))?;
113 let format = v.get("format").cloned().unwrap_or(Value::Null);
114 let empty = Vec::new();
115 let streams = v.get("streams").and_then(Value::as_array).unwrap_or(&empty);
116
117 let mut a = Asset {
118 id: 0,
119 path: path.to_string(),
120 kind: ClipKind::Audio,
121 duration: f64_of(&format, "duration"),
122 width: 0,
123 height: 0,
124 fps: 0.0,
125 audio_streams: Vec::new(),
126 codec: String::new(),
127 folder: String::new(),
128 tags: Vec::new(),
129 label: 0,
130 description: String::new(),
131 };
132 let mut video: Option<&Value> = None;
133 let mut stream_dur: f64 = 0.0;
134 for s in streams {
135 stream_dur = stream_dur.max(f64_of(s, "duration"));
136 match str_of(s, "codec_type") {
137 "video" => {
138 let attached = s.pointer("/disposition/attached_pic").and_then(Value::as_i64).unwrap_or(0) != 0;
139 if video.is_none() && !attached {
140 video = Some(s);
141 }
142 }
143 "audio" => {
144 let tags = s.get("tags").cloned().unwrap_or(Value::Null);
145 a.audio_streams.push(AudioStreamInfo {
146 index: a.audio_streams.len(),
147 channels: f64_of(s, "channels") as u32,
148 sample_rate: f64_of(s, "sample_rate") as u32,
149 language: str_of(&tags, "language").to_string(),
150 title: str_of(&tags, "title").to_string(),
151 codec: str_of(s, "codec_name").to_string(),
152 });
153 }
154 _ => {}
155 }
156 }
157 if a.duration <= 0.0 {
158 a.duration = stream_dur;
159 }
160 if let Some(s) = video {
161 a.width = f64_of(s, "width") as u32;
162 a.height = f64_of(s, "height") as u32;
163 let rot = s.get("side_data_list").and_then(Value::as_array).into_iter().flatten();
165 let rot = rot.map(|d| f64_of(d, "rotation")).find(|r| *r != 0.0).unwrap_or(0.0);
166 if (rot.abs().round() as i64) % 180 == 90 {
167 std::mem::swap(&mut a.width, &mut a.height);
168 }
169 a.codec = str_of(s, "codec_name").to_string();
170 a.fps = parse_rate(str_of(s, "avg_frame_rate"));
171 if a.fps <= 0.0 {
172 a.fps = parse_rate(str_of(s, "r_frame_rate"));
173 }
174 let single = matches!(a.codec.as_str(), "png" | "mjpeg" | "bmp" | "webp" | "tiff" | "gif")
175 && f64_of(s, "nb_frames") <= 1.0
176 && f64_of(&format, "duration") <= 0.0;
177 let animated = a.codec == "gif" && f64_of(s, "nb_frames") > 1.0;
179 let is_image =
180 !animated && (is_image_path(path) || str_of(&format, "format_name").ends_with("_pipe") || single);
181 a.kind = if is_image { ClipKind::Image } else { ClipKind::Video };
182 if is_image {
183 a.duration = 0.0;
184 }
185 } else if is_image_path(path) {
186 a.kind = ClipKind::Image;
187 a.duration = 0.0;
188 }
189 if a.duration <= 0.0 && a.kind != ClipKind::Image && (video.is_some() || !a.audio_streams.is_empty()) {
191 a.duration = packet_duration(&exe, path, if video.is_some() { "v:0" } else { "a:0" });
192 }
193 Ok(a)
194}
195
196fn packet_duration(exe: &std::path::Path, path: &str, sel: &str) -> f64 {
200 let entries = "packet=pts_time,duration_time";
201 let Ok(out) = command(exe)
202 .args(["-v", "error", "-select_streams", sel, "-show_entries", entries, "-of", "csv=p=0", path])
203 .stdin(Stdio::null())
204 .output()
205 else {
206 return 0.0;
207 };
208 let (mut start, mut end, mut sum) = (f64::INFINITY, 0.0f64, 0.0f64);
209 for line in String::from_utf8_lossy(&out.stdout).lines() {
210 let mut f = line.split(',').map(|x| x.trim().parse::<f64>().ok());
211 let (pts, dur) = (f.next().flatten(), f.next().flatten().unwrap_or(0.0));
212 sum += dur;
213 if let Some(pts) = pts {
214 start = start.min(pts);
215 end = end.max(pts + dur);
216 }
217 }
218 if start.is_finite() {
219 (end - start).max(0.0)
220 } else {
221 sum
222 }
223}
224
225#[cfg(test)]
228thread_local!(static SPAWNS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) });
229
230fn spawn(args: &[String]) -> Result<(Child, ChildStdout), String> {
231 #[cfg(test)]
232 SPAWNS.with(|c| c.set(c.get() + 1));
233 let exe = ffmpeg_exe().ok_or("ffmpeg.exe not found")?;
234 let mut child = command(&exe)
235 .args(["-nostdin", "-loglevel", "error"])
236 .args(args)
237 .stdin(Stdio::null())
238 .stdout(Stdio::piped())
239 .stderr(Stdio::null()) .spawn()
241 .map_err(|e| format!("ffmpeg: {e}"))?;
242 let out = child.stdout.take().ok_or("ffmpeg: no stdout")?;
243 Ok((child, out))
244}
245
246fn kill(child: &mut Option<(Child, ChildStdout)>) {
247 if let Some((mut c, out)) = child.take() {
248 let _ = c.kill();
249 drop(out);
250 let _ = c.wait();
251 }
252}
253
254struct VideoPipe {
257 path: String,
258 size: (u32, u32),
259 fps: f64,
260 duration: f64,
261 child: Option<(Child, ChildStdout)>,
262 out_size: (u32, u32),
264 next: i64,
266 cur: Frame,
268 have: bool,
269 end: f64,
271 scaled: Vec<u8>,
273 skey: (i64, u32, u32),
274}
275
276impl VideoPipe {
277 fn respawn(&mut self, idx: i64, w: u32, h: u32) -> bool {
278 kill(&mut self.child);
279 self.have = false;
280 self.skey = (-1, 0, 0);
281 let t = (idx as f64 / self.fps - 0.0005).max(0.0);
283 let args = [
284 "-ss".into(),
285 format!("{t:.6}"),
286 "-i".into(),
287 self.path.clone(),
288 "-map".into(),
289 "0:v:0".into(),
290 "-f".into(),
291 "rawvideo".into(),
292 "-pix_fmt".into(),
293 "rgba".into(),
294 "-s".into(),
295 format!("{w}x{h}"),
296 "-fps_mode".into(),
297 "cfr".into(),
298 "-r".into(),
299 format!("{}", self.fps),
300 "-an".into(),
301 "-sn".into(),
302 "pipe:1".into(),
303 ];
304 match spawn(&args) {
305 Ok(c) => {
306 self.child = Some(c);
307 self.out_size = (w, h);
308 self.next = idx;
309 self.cur.resize(w, h);
310 true
311 }
312 Err(_) => false,
313 }
314 }
315 fn read_next(&mut self) -> bool {
317 let Some((_, out)) = self.child.as_mut() else {
318 return false;
319 };
320 if out.read_exact(&mut self.cur.rgba).is_ok() {
321 self.cur.pts = self.next as f64 / self.fps;
322 self.next += 1;
323 self.have = true;
324 true
325 } else {
326 self.end = self.end.min(self.next as f64 / self.fps);
327 kill(&mut self.child);
328 false
329 }
330 }
331}
332
333impl VideoSource for VideoPipe {
334 fn size(&self) -> (u32, u32) {
335 self.size
336 }
337 fn frame_at(&mut self, t: f64, w: u32, h: u32, out: &mut Frame) -> bool {
338 if w == 0 || h == 0 || t >= self.end || (self.duration > 0.0 && t >= self.duration + 0.5 / self.fps) {
339 return false;
340 }
341 let t = t.max(0.0);
342 let idx = (t * self.fps + 1e-6).floor() as i64;
343 let fits = w <= self.out_size.0 && h <= self.out_size.1;
345 if !(fits && self.have && idx == self.next - 1) {
346 let sequential = self.child.is_some() && idx >= self.next - 1 && t <= self.cur.pts + 1.5;
347 if !sequential || !fits {
348 let (sw, sh) = if sequential { (w.max(self.size.0), h.max(self.size.1)) } else { (w, h) };
351 if !self.respawn(idx, sw, sh) {
352 return false;
353 }
354 }
355 while self.next <= idx {
356 if !self.read_next() {
357 return false;
358 }
359 }
360 if !self.have {
361 return false;
362 }
363 }
364 out.resize(w, h);
365 out.pts = self.cur.pts;
366 if (w, h) == self.out_size {
367 out.rgba.copy_from_slice(&self.cur.rgba);
368 } else {
369 let key = (self.next - 1, w, h);
370 if self.skey != key {
371 self.scaled.resize((w * h * 4) as usize, 0);
372 box_down(&self.cur.rgba, self.out_size.0, self.out_size.1, w, h, &mut self.scaled);
373 self.skey = key;
374 }
375 out.rgba.copy_from_slice(&self.scaled);
376 }
377 true
378 }
379}
380
381fn box_down(src: &[u8], sw: u32, sh: u32, w: u32, h: u32, dst: &mut [u8]) {
383 debug_assert!(w <= sw && h <= sh);
384 let (sw, sh, w, h) = (sw as usize, sh as usize, w as usize, h as usize);
385 for (j, drow) in dst.chunks_exact_mut(w * 4).enumerate() {
386 let (y0, y1) = (j * sh / h, (j + 1) * sh / h);
387 for (i, d) in drow.chunks_exact_mut(4).enumerate() {
388 let (x0, x1) = (i * sw / w, (i + 1) * sw / w);
389 let mut acc = [0u32; 4];
390 for y in y0..y1 {
391 for px in src[(y * sw + x0) * 4..(y * sw + x1) * 4].chunks_exact(4) {
392 for k in 0..4 {
393 acc[k] += px[k] as u32;
394 }
395 }
396 }
397 let n = ((y1 - y0) * (x1 - x0)).max(1) as u32;
398 for k in 0..4 {
399 d[k] = (acc[k] / n) as u8;
400 }
401 }
402 }
403}
404
405impl Drop for VideoPipe {
406 fn drop(&mut self) {
407 kill(&mut self.child);
408 }
409}
410
411struct ImageSource {
413 path: String,
414 size: (u32, u32),
415 cache: HashMap<(u32, u32), Vec<u8>>,
416}
417
418impl VideoSource for ImageSource {
419 fn size(&self) -> (u32, u32) {
420 self.size
421 }
422 fn frame_at(&mut self, _t: f64, w: u32, h: u32, out: &mut Frame) -> bool {
423 if w == 0 || h == 0 {
424 return false;
425 }
426 if !self.cache.contains_key(&(w, h)) {
427 if self.cache.len() >= 8 {
428 self.cache.clear(); }
430 let args: Vec<String> = [
431 "-i",
432 &self.path,
433 "-frames:v",
434 "1",
435 "-f",
436 "rawvideo",
437 "-pix_fmt",
438 "rgba",
439 "-s",
440 &format!("{w}x{h}"),
441 "-an",
442 "-sn",
443 "pipe:1",
444 ]
445 .iter()
446 .map(|s| s.to_string())
447 .collect();
448 let Ok((mut child, mut pipe)) = spawn(&args) else {
449 return false;
450 };
451 let mut buf = Vec::with_capacity((w * h * 4) as usize);
452 let ok = pipe.read_to_end(&mut buf).is_ok();
453 let _ = child.wait();
454 if !ok || buf.len() != (w * h * 4) as usize {
455 return false;
456 }
457 self.cache.insert((w, h), buf);
458 }
459 let Some(rgba) = self.cache.get(&(w, h)) else {
460 return false;
461 };
462 out.resize(w, h);
463 out.rgba.copy_from_slice(rgba);
464 out.pts = 0.0;
465 true
466 }
467}
468
469pub fn open_video(path: &str) -> Result<Box<dyn VideoSource>, String> {
470 let a = probe(path)?;
471 if a.kind == ClipKind::Image {
472 return Ok(Box::new(ImageSource { path: path.to_string(), size: (a.width, a.height), cache: HashMap::new() }));
473 }
474 if a.kind != ClipKind::Video {
475 return Err("no video stream".into());
476 }
477 let fps = if a.fps > 0.0 { a.fps } else { 30.0 };
478 Ok(Box::new(VideoPipe {
479 path: path.to_string(),
480 size: (a.width, a.height),
481 fps,
482 duration: a.duration,
483 child: None,
484 out_size: (0, 0),
485 next: 0,
486 cur: Frame::default(),
487 have: false,
488 end: f64::INFINITY,
489 scaled: Vec::new(),
490 skey: (-1, 0, 0),
491 }))
492}
493
494const AUDIO_CHUNK_FRAMES: usize = 4800;
497
498struct AudioPipe {
499 path: String,
500 stream: usize,
501 channels: u32,
502 duration: f64,
503 child: Option<(Child, ChildStdout)>,
504 buf: Vec<f32>,
506 pos: usize,
507 bytes: Vec<u8>,
508 next: i64,
510 end: i64,
512}
513
514impl AudioPipe {
515 fn respawn(&mut self, frame: i64) -> bool {
516 kill(&mut self.child);
517 self.buf.clear();
518 self.pos = 0;
519 self.next = frame;
520 let t = frame as f64 / SAMPLE_RATE as f64;
521 let mut args: Vec<String> =
522 ["-ss", &format!("{t:.6}"), "-i", &self.path, "-map", &format!("0:a:{}", self.stream)]
523 .iter()
524 .map(|s| s.to_string())
525 .collect();
526 if self.channels == 1 {
527 args.extend(["-af", "pan=stereo|c0=c0|c1=c0"].map(String::from));
529 }
530 args.extend(["-f", "f32le", "-ac", "2", "-ar", "48000", "-vn", "pipe:1"].map(String::from));
531 match spawn(&args) {
532 Ok(c) => {
533 self.child = Some(c);
534 true
535 }
536 Err(_) => false,
537 }
538 }
539 fn refill(&mut self) -> bool {
541 let Some((_, out)) = self.child.as_mut() else {
542 return false;
543 };
544 self.bytes.resize(AUDIO_CHUNK_FRAMES * 8, 0);
545 let mut got = 0;
546 while got < self.bytes.len() {
547 match out.read(&mut self.bytes[got..]) {
548 Ok(0) | Err(_) => break,
549 Ok(n) => got += n,
550 }
551 }
552 let got = got / 4 * 4;
553 self.buf.clear();
554 self.pos = 0;
555 self.buf.extend(self.bytes[..got].chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])));
556 if self.buf.len() % 2 == 1 {
557 self.buf.pop();
558 }
559 if self.buf.is_empty() {
560 self.end = self.end.min(self.next);
561 kill(&mut self.child);
562 return false;
563 }
564 true
565 }
566}
567
568impl AudioSource for AudioPipe {
569 fn duration(&self) -> f64 {
570 self.duration
571 }
572 fn read_at(&mut self, t: f64, out: &mut [f32]) {
573 let want = (t.max(0.0) * SAMPLE_RATE as f64).round() as i64;
574 let frames = out.len() / 2;
575 if want >= self.end {
576 out.fill(0.0);
577 return;
578 }
579 if self.child.is_some() && want < self.next {
583 let back = (self.next - want) as usize;
584 if back * 2 <= self.pos {
585 self.pos -= back * 2;
586 self.next = want;
587 }
588 }
589 let sequential = self.child.is_some() && want >= self.next && want <= self.next + SAMPLE_RATE as i64 / 2;
590 if !sequential && !self.respawn(want) {
591 out.fill(0.0);
592 return;
593 }
594 let mut done = 0usize; let mut skip = (want - self.next) as usize;
597 while done < frames {
598 if self.pos >= self.buf.len() && !self.refill() {
599 break;
600 }
601 let avail = (self.buf.len() - self.pos) / 2;
602 if skip > 0 {
603 let n = skip.min(avail);
604 self.pos += n * 2;
605 self.next += n as i64;
606 skip -= n;
607 continue;
608 }
609 let n = avail.min(frames - done);
610 out[done * 2..(done + n) * 2].copy_from_slice(&self.buf[self.pos..self.pos + n * 2]);
611 self.pos += n * 2;
612 self.next += n as i64;
613 done += n;
614 }
615 out[done * 2..].fill(0.0);
616 }
617}
618
619impl Drop for AudioPipe {
620 fn drop(&mut self) {
621 kill(&mut self.child);
622 }
623}
624
625pub fn open_audio(path: &str, stream: usize) -> Result<Box<dyn AudioSource>, String> {
626 let a = probe(path)?;
627 if stream >= a.audio_streams.len() {
628 return Err(format!("no audio stream {stream}"));
629 }
630 Ok(Box::new(AudioPipe {
631 path: path.to_string(),
632 stream,
633 channels: a.audio_streams[stream].channels,
634 duration: a.duration,
635 child: None,
636 buf: Vec::new(),
637 pos: 0,
638 bytes: Vec::new(),
639 next: 0,
640 end: i64::MAX,
641 }))
642}
643
644#[cfg(test)]
647pub(crate) mod tests {
648 use super::*;
649 use std::path::Path;
650 use std::sync::OnceLock;
651
652 pub(crate) fn test_mp4() -> String {
654 static P: OnceLock<String> = OnceLock::new();
655 P.get_or_init(|| {
656 let dir = std::env::temp_dir().join(format!("simple-editor-ffpipe-tests-{}", std::process::id()));
660 let _ = std::fs::create_dir_all(&dir);
661 let p = dir.join("test.mp4");
662 let st = Command::new("ffmpeg")
663 .args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "color=red:s=320x240:d=2", "-f", "lavfi", "-i"])
664 .args(["color=lime:s=320x240:d=2", "-f", "lavfi", "-i", "sine=frequency=440:duration=4", "-f", "lavfi"])
665 .args(["-i", "sine=frequency=880:duration=4", "-filter_complex", "[0:v][1:v]concat=n=2:v=1[v]"])
666 .args([
667 "-map", "[v]", "-map", "2:a", "-map", "3:a", "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264",
668 ])
669 .args(["-c:a", "aac", "-metadata:s:a:0", "language=eng", "-metadata:s:a:1", "title=Music"])
670 .arg(&p)
671 .status()
672 .expect("ffmpeg on PATH");
673 assert!(st.success(), "ffmpeg failed to generate test media");
674 p.to_string_lossy().into_owned()
675 })
676 .clone()
677 }
678
679 fn test_mkv() -> String {
681 let p = Path::new(&test_mp4()).with_file_name("title.mkv");
682 let st = Command::new("ffmpeg")
683 .args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-c:a", "aac"])
684 .args(["-metadata:s:a:0", "title=Music"])
685 .arg(&p)
686 .status()
687 .expect("ffmpeg on PATH");
688 assert!(st.success());
689 p.to_string_lossy().into_owned()
690 }
691
692 fn test_png() -> String {
693 let p = Path::new(&test_mp4()).with_file_name("x.png");
694 let st = Command::new("ffmpeg")
695 .args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "color=blue:s=64x48", "-frames:v", "1"])
696 .arg(&p)
697 .status()
698 .expect("ffmpeg on PATH");
699 assert!(st.success());
700 p.to_string_lossy().into_owned()
701 }
702
703 fn centre(f: &Frame) -> [u8; 4] {
704 let i = ((f.height / 2 * f.width + f.width / 2) * 4) as usize;
705 [f.rgba[i], f.rgba[i + 1], f.rgba[i + 2], f.rgba[i + 3]]
706 }
707 fn is_red(p: [u8; 4]) -> bool {
708 p[0] > 200 && p[1] < 60 && p[2] < 60 && p[3] == 255
709 }
710 fn is_green(p: [u8; 4]) -> bool {
711 p[0] < 60 && p[1] > 200 && p[2] < 60 && p[3] == 255
712 }
713 fn rms(s: &[f32]) -> f32 {
714 (s.iter().map(|x| x * x).sum::<f32>() / s.len() as f32).sqrt()
715 }
716 fn crossings(s: &[f32]) -> usize {
718 s.chunks_exact(2).map(|f| f[0]).collect::<Vec<_>>().windows(2).filter(|w| (w[0] < 0.0) != (w[1] < 0.0)).count()
719 }
720
721 #[test]
722 fn parse_rate_forms() {
723 assert!((parse_rate("30000/1001") - 29.97).abs() < 0.01);
724 assert_eq!(parse_rate("30/1"), 30.0);
725 assert_eq!(parse_rate("0/0"), 0.0);
726 assert_eq!(parse_rate("25"), 25.0);
727 assert_eq!(parse_rate("x"), 0.0);
728 }
729
730 #[test]
731 fn probe_mp4() {
732 let a = probe(&test_mp4()).unwrap();
733 assert_eq!(a.kind, ClipKind::Video);
734 assert!((a.duration - 4.0).abs() < 0.1, "{}", a.duration);
735 assert_eq!((a.width, a.height), (320, 240));
736 assert!((a.fps - 30.0).abs() < 0.01);
737 assert_eq!(a.codec, "h264");
738 assert_eq!(a.audio_streams.len(), 2);
739 assert_eq!(a.audio_streams[0].index, 0);
740 assert_eq!(a.audio_streams[0].language, "eng");
741 assert_eq!(a.audio_streams[1].index, 1);
742 assert_eq!(a.audio_streams[1].codec, "aac");
743 assert_eq!(a.audio_streams[1].channels, 1);
744 assert_eq!(a.audio_streams[1].sample_rate, 44100);
745 assert!(probe("Z:/definitely/missing.mp4").is_err());
746 let m = probe(&test_mkv()).unwrap();
747 assert_eq!(m.kind, ClipKind::Audio);
748 assert_eq!(m.audio_streams[0].title, "Music");
749 assert!((m.duration - 1.0).abs() < 0.1, "{}", m.duration);
750 }
751
752 #[test]
753 fn video_decode_seek_scale() {
754 let mut v = open_video(&test_mp4()).unwrap();
755 assert_eq!(v.size(), (320, 240));
756 let mut f = Frame::default();
757 assert!(v.frame_at(0.5, 320, 240, &mut f));
758 assert!(is_red(centre(&f)), "{:?}", centre(&f));
759 assert!((f.pts - 0.5).abs() < 0.02);
760 assert!(v.frame_at(0.5 + 1.0 / 30.0, 320, 240, &mut f));
762 assert!(is_red(centre(&f)));
763 assert!(v.frame_at(2.5, 320, 240, &mut f));
765 assert!(is_green(centre(&f)), "{:?}", centre(&f));
766 assert!((f.pts - 2.5).abs() < 0.02);
767 assert!(v.frame_at(2.5, 320, 240, &mut f));
769 assert!(is_green(centre(&f)));
770 assert!(v.frame_at(0.5, 320, 240, &mut f));
772 assert!(is_red(centre(&f)));
773 assert!(v.frame_at(1.0, 160, 120, &mut f));
775 assert_eq!((f.width, f.height), (160, 120));
776 assert!(is_red(centre(&f)));
777 assert!(v.frame_at(3.97, 160, 120, &mut f));
779 assert!(is_green(centre(&f)));
780 assert!(!v.frame_at(10.0, 160, 120, &mut f));
781 assert!(!v.frame_at(4.5, 160, 120, &mut f));
782 assert!(v.frame_at(2.5, 160, 120, &mut f));
784 assert!(is_green(centre(&f)));
785 }
786
787 #[test]
788 fn audio_reads_with_guard_frame_do_not_respawn() {
789 let path = test_mp4();
790 let Ok(mut a) = open_audio(&path, 0) else { return };
791 let mut buf = vec![0f32; 4800 * 2];
792 let before = SPAWNS.with(|s| s.get());
793 let mut t = 0.5;
795 for _ in 0..20 {
796 a.read_at(t, &mut buf);
797 t += (4800 - 1) as f64 / SAMPLE_RATE as f64;
798 }
799 let spawns = SPAWNS.with(|s| s.get()) - before;
800 assert!(spawns <= 1, "{spawns} ffmpeg respawns for 20 near-sequential audio blocks");
801 }
802
803 #[test]
804 fn audio_decode() {
805 let mut a = open_audio(&test_mp4(), 0).unwrap();
806 assert!((a.duration() - 4.0).abs() < 0.1);
807 let mut buf = vec![0f32; 4800 * 2];
808 a.read_at(0.5, &mut buf);
809 let r = rms(&buf);
811 assert!(r > 0.07 && r < 0.11, "rms {r}");
812 assert!(buf.chunks_exact(2).all(|f| f[0] == f[1]));
813 let c = crossings(&buf);
814 assert!((80..=96).contains(&c), "440 Hz crossings {c}");
815 a.read_at(0.6, &mut buf);
817 assert!(rms(&buf) > 0.07);
818 a.read_at(0.1, &mut buf);
820 assert!(rms(&buf) > 0.07);
821 a.read_at(5.0, &mut buf);
823 assert!(buf.iter().all(|&x| x == 0.0));
824 a.read_at(1.0, &mut buf);
825 assert!(rms(&buf) > 0.07);
826 let mut b = open_audio(&test_mp4(), 1).unwrap();
828 b.read_at(0.5, &mut buf);
829 let c = crossings(&buf);
830 assert!((168..=184).contains(&c), "880 Hz crossings {c}");
831 assert!(open_audio(&test_mp4(), 2).is_err());
832 }
833
834 #[test]
835 fn image_decode() {
836 let p = test_png();
837 let a = probe(&p).unwrap();
838 assert_eq!(a.kind, ClipKind::Image);
839 assert_eq!((a.width, a.height), (64, 48));
840 assert_eq!(a.duration, 0.0);
841 let mut v = open_video(&p).unwrap();
842 let mut f = Frame::default();
843 assert!(v.frame_at(0.0, 32, 24, &mut f));
844 assert_eq!((f.width, f.height), (32, 24));
845 let px = centre(&f);
846 assert!(px[0] < 10 && px[1] < 10 && px[2] > 240 && px[3] == 255, "{px:?}");
847 assert!(v.frame_at(5.0, 32, 24, &mut f));
848 assert!(v.frame_at(5.0, 64, 48, &mut f));
849 assert_eq!((f.width, f.height), (64, 48));
850 }
851
852 #[test]
854 fn animated_gif_is_video() {
855 let p = Path::new(&test_mp4()).with_file_name(format!("{}-anim.gif", std::process::id()));
856 let st = Command::new("ffmpeg")
857 .args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "color=red:s=64x48:d=1,format=rgb8"])
858 .args(["-f", "lavfi", "-i", "color=blue:s=64x48:d=1,format=rgb8"])
859 .args(["-filter_complex", "[0:v][1:v]concat=n=2:v=1[v]", "-map", "[v]", "-r", "10"])
860 .arg(&p)
861 .status()
862 .expect("ffmpeg on PATH");
863 assert!(st.success());
864 let p = p.to_string_lossy().into_owned();
865 let a = probe(&p).unwrap();
866 assert_eq!(a.kind, ClipKind::Video);
867 assert!((a.duration - 2.0).abs() < 0.5, "{}", a.duration);
868 let mut v = crate::media::open_video(&p, crate::media::Backend::Auto).unwrap();
869 let mut f = Frame::default();
870 assert!(v.frame_at(0.5, 64, 48, &mut f));
871 assert!(is_red(centre(&f)), "{:?}", centre(&f));
872 assert!(v.frame_at(1.5, 64, 48, &mut f));
873 let c = centre(&f);
874 assert!(c[2] > 150 && c[0] < 60, "{c:?}"); let still = Path::new(&test_mp4()).with_file_name(format!("{}-still.gif", std::process::id()));
876 let st = Command::new("ffmpeg")
877 .args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "color=blue:s=64x48", "-frames:v", "1"])
878 .arg(&still)
879 .status()
880 .expect("ffmpeg on PATH");
881 assert!(st.success());
882 assert_eq!(probe(&still.to_string_lossy()).unwrap().kind, ClipKind::Image);
883 }
884
885 #[test]
887 fn probe_duration_from_packets() {
888 let p = Path::new(&test_mp4()).with_file_name(format!("{}-stdout.webm", std::process::id()));
889 let out = std::fs::File::create(&p).unwrap();
890 let st = Command::new("ffmpeg")
891 .args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "color=red:s=160x120:d=3", "-f", "lavfi", "-i"])
892 .args(["sine=duration=3", "-c:v", "libvpx", "-c:a", "libopus", "-f", "webm", "-"])
893 .stdout(out)
894 .status()
895 .expect("ffmpeg on PATH");
896 assert!(st.success());
897 let a = probe(&p.to_string_lossy()).unwrap();
898 assert_eq!(a.kind, ClipKind::Video);
899 assert!((a.duration - 3.0).abs() < 0.2, "{}", a.duration);
900 assert_eq!((a.width, a.height), (160, 120));
901 assert_eq!(a.audio_streams.len(), 1);
902 }
903
904 #[test]
907 fn scale_keeps_pipe() {
908 let mut v = open_video(&test_mp4()).unwrap();
909 let mut f = Frame::default();
910 let sizes = [(80, 60), (120, 90), (160, 120)];
911 let before = SPAWNS.with(|c| c.get());
912 let t0 = std::time::Instant::now();
913 for n in 0..=60 {
914 let t = 0.5 + n as f64 / 30.0;
915 let (w, h) = sizes[n % 3];
916 assert!(v.frame_at(t, w, h, &mut f), "n={n}");
917 assert_eq!((f.width, f.height), (w, h));
918 let c = centre(&f);
919 assert!(if t < 2.0 { is_red(c) } else { is_green(c) }, "t={t} {c:?}");
920 }
921 let spawns = SPAWNS.with(|c| c.get()) - before;
922 eprintln!("scale_keeps_pipe: 61 frames, {spawns} spawns, {:?}", t0.elapsed());
923 assert_eq!(spawns, 2);
925 assert!(v.frame_at(2.5, 80, 60, &mut f));
927 assert_eq!(SPAWNS.with(|c| c.get()) - before, 2);
928 assert!(v.frame_at(0.5, 80, 60, &mut f));
930 assert!(is_red(centre(&f)));
931 assert_eq!(SPAWNS.with(|c| c.get()) - before, 3);
932 assert!(v.frame_at(0.5 + 1.0 / 30.0, 40, 30, &mut f));
933 assert_eq!((f.width, f.height), (40, 30));
934 assert!(is_red(centre(&f)));
935 assert_eq!(SPAWNS.with(|c| c.get()) - before, 3);
936 }
937
938 #[test]
939 fn box_down_averages() {
940 #[rustfmt::skip]
942 let src = [
943 0, 0, 0, 255, 100, 100, 100, 255, 200, 0, 0, 255, 200, 0, 0, 255,
944 0, 0, 0, 255, 100, 100, 100, 255, 0, 0, 0, 255, 0, 0, 0, 255,
945 ];
946 let mut dst = [0u8; 8];
947 box_down(&src, 4, 2, 2, 1, &mut dst);
948 assert_eq!(dst, [50, 50, 50, 255, 100, 0, 0, 255]);
949 }
950}