simple_editor\media/
ffpipe.rs

1//! ffmpeg.exe / ffprobe.exe child-process backend. Universal fallback decoder, image loader, prober,
2//! and the process launcher used by export.
3//!
4//! Contract (see media/mod.rs):
5//!  * `ffmpeg_exe()` / `ffprobe_exe()` locate the binaries: Settings.ffmpeg_dir (set via `set_dir`),
6//!    then next to the app exe, then `ffmpeg/` beside the exe, then PATH (a few stats per call, not cached).
7//!  * `command(exe)` returns a std Command with CREATE_NO_WINDOW (0x08000000) so no console flashes.
8//!  * `probe(path)` -> Asset via `ffprobe -v error -print_format json -show_streams -show_format`.
9//!  * `open_video(path)` -> VideoSource: `ffmpeg -ss T -i path -map 0:v:0 -f rawvideo -pix_fmt rgba -s WxH
10//!    -fps_mode cfr -r FPS -an -sn pipe:1`, read W*H*4 bytes per frame (pts = T + n/fps); restart on
11//!    non-sequential seeks (at the requested size) or when a request outgrows the pipe (then at native size,
12//!    so later sizes never restart); smaller requests are shrunk in Rust. Images (1 frame) are decoded once
13//!    per size and cached.
14//!  * `open_audio(path, stream)` -> AudioSource: `ffmpeg -ss T -i path -map 0:a:N -f f32le -ac 2 -ar 48000 pipe:1`.
15//!  Kill children on Drop.
16
17use 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
28/// Set the user-configured ffmpeg directory ("" = auto).
29pub 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
45/// Locate a helper executable: `dir` (when set), then next to our exe (and an `ffmpeg` subfolder),
46/// then PATH. Shared with `media::ytdlp` — not cached, so callers must not run it every frame.
47pub(crate) fn find_exe(name: &str, dir: &str) -> Option<PathBuf> {
48    find_all_exe(name, dir).into_iter().next()
49}
50
51/// Every place `name` exists, in lookup order. `ytdlp` needs this because a PATH entry can hold a
52/// broken shim (a pip launcher for an uninstalled Python) that must be skipped for the next one.
53pub(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
76/// A Command for `exe` that never opens a console window.
77pub 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); // CREATE_NO_WINDOW
81    c
82}
83
84// ---------------------------------------------------------------- probe
85
86fn 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}
92/// "30000/1001" -> 29.97; "0/0" / garbage -> 0.
93fn 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        // Display Matrix rotation (phone portrait): ffmpeg autorotates frames, so report display dims.
164        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        // Animated GIFs are video (is_image_path still routes every GIF to ffmpeg, which is fine).
178        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    // Duration-less containers (stream-written WebM, raw .h264/.aac, some TS): measure the packets.
190    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
196/// Duration of stream `sel` from its packets: max(pts_time + duration_time) - min(pts_time); when no packet
197/// carries a pts (raw .h264) the durations are summed instead. Full demux, so only for files whose container
198/// has no duration. 0.0 on failure.
199fn 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// ---------------------------------------------------------------- helpers
226
227#[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()) // ponytail: errors dropped — read stderr on a thread if diagnostics matter
240        .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
254// ---------------------------------------------------------------- video
255
256struct VideoPipe {
257    path: String,
258    size: (u32, u32),
259    fps: f64,
260    duration: f64,
261    child: Option<(Child, ChildStdout)>,
262    /// Output size of the running process.
263    out_size: (u32, u32),
264    /// Absolute frame index of the next frame the pipe will deliver.
265    next: i64,
266    /// Last delivered frame (index `next-1`), valid when `have` is true.
267    cur: Frame,
268    have: bool,
269    /// Earliest known end (set when the pipe hits EOF) — avoids respawn storms past the end.
270    end: f64,
271    /// `cur` shrunk to `skey` = (frame index, w, h): repeated calls at the same t and size are free.
272    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        // Half a millisecond early so rounding never lands just past frame `idx` (ffmpeg drops frames < T).
282        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    /// Read the next frame into `cur`. False at EOF (child is reaped, `end` updated).
316    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        // The pipe's output is only ever shrunk in Rust, so any request that fits keeps it running.
344        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                // Outgrown on the same t progression (keyframed scale): respawn once at native so later sizes
349                // never respawn. Real seek: the requested size.
350                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
381/// Area-average downscale of top-down RGBA (`w <= sw`, `h <= sh`).
382fn 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
411/// Still image: decoded once per requested size (`-frames:v 1`), `t` ignored.
412struct 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(); // ponytail: bounded cache for animated scale — resize in Rust if it thrashes
429            }
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
494// ---------------------------------------------------------------- audio
495
496const 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    /// Decoded interleaved stereo; `buf[pos..]` is unread.
505    buf: Vec<f32>,
506    pos: usize,
507    bytes: Vec<u8>,
508    /// Absolute sample-frame index of `buf[pos]`.
509    next: i64,
510    /// Frame index at which the pipe hit EOF (i64::MAX = unknown).
511    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            // Duplicate mono into both channels (swresample's default upmix attenuates by 3 dB).
528            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    /// Refill `buf` from the pipe. False at EOF.
540    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        // A small step back (the mixer reads one guard frame beyond what it consumes, and resampling
580        // re-reads the block edge) is served from the frames still sitting behind `pos` — respawning
581        // ffmpeg for one frame would cost ~50-100 ms per block.
582        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        // Skip forward (small gaps), then copy.
595        let mut done = 0usize; // frames written
596        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// ---------------------------------------------------------------- tests
645
646#[cfg(test)]
647pub(crate) mod tests {
648    use super::*;
649    use std::path::Path;
650    use std::sync::OnceLock;
651
652    /// Generates the shared test clip once: red 0–2 s, green 2–4 s, 440 Hz (eng) + 880 Hz (Music).
653    pub(crate) fn test_mp4() -> String {
654        static P: OnceLock<String> = OnceLock::new();
655        P.get_or_init(|| {
656            // ponytail: per-process dir — `OnceLock` only serialises within one test binary, and two
657            // concurrent `cargo test` runs used to truncate each other's fixture mid-read ("moov atom
658            // not found"). Costs one re-encode per run, which the unconditional `-y` already paid.
659            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    /// mkv keeps per-stream titles (mp4 drops them).
680    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    /// Zero crossings of the left channel.
717    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        // sequential
761        assert!(v.frame_at(0.5 + 1.0 / 30.0, 320, 240, &mut f));
762        assert!(is_red(centre(&f)));
763        // forward jump (respawn)
764        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        // repeated call is cached
768        assert!(v.frame_at(2.5, 320, 240, &mut f));
769        assert!(is_green(centre(&f)));
770        // seek back
771        assert!(v.frame_at(0.5, 320, 240, &mut f));
772        assert!(is_red(centre(&f)));
773        // scaled
774        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        // boundary: last frame exists, past end is false
778        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        // still usable after EOF
783        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        // the mixer's pattern: read [t, t+0.1) but only consume 0.1 s minus one frame each time
794        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        // ffmpeg's sine source is ~-18 dBFS (peak 0.125, rms 0.088); mono is duplicated, not attenuated.
810        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        // sequential continues seamlessly (no discontinuity spike)
816        a.read_at(0.6, &mut buf);
817        assert!(rms(&buf) > 0.07);
818        // seek back
819        a.read_at(0.1, &mut buf);
820        assert!(rms(&buf) > 0.07);
821        // past end is silent, then works again
822        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        // second stream is 880 Hz
827        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    /// Animated GIFs are video (every frame reachable); single-frame GIFs stay images.
853    #[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:?}"); // rgb8 palette: blue = 170
875        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    /// A WebM streamed to stdout carries no duration anywhere; probe must measure the packets.
886    #[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    /// Size changes on a sequential t progression (keyframed scale) must not respawn ffmpeg: the pipe is
905    /// shrunk in Rust, and one growth respawns at native size after which every size fits.
906    #[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        // first (80x60) + one growth (120x90 -> native 320x240); 160x120 then fits
924        assert_eq!(spawns, 2);
925        // repeated call at the same t/size is free
926        assert!(v.frame_at(2.5, 80, 60, &mut f));
927        assert_eq!(SPAWNS.with(|c| c.get()) - before, 2);
928        // a real seek still respawns (at the requested size)
929        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        // 4x2 -> 2x1: each output pixel averages a 2x2 box.
941        #[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}