simple_editor\media/
proxy.rs

1//! Proxy media: background all-intra low-res transcodes of imported video, played instead of the
2//! originals in the preview. Every proxy frame is a keyframe (`-g 1`), so seeks, reverse scrubs and
3//! clip-boundary cold opens decode without reference chains — the reason big NLEs feel instant.
4//! Proxies live in the cache dir, named by a hash of (source path, mtime, height): a re-exported
5//! source gets a fresh proxy automatically and stale files are just never referenced again.
6//! Export and full-quality one-shot renders never see proxies (their DecoderPools carry no map).
7
8use crate::engine::export::{self, Progress};
9use crate::media::ffpipe;
10use std::io::BufRead;
11use std::path::PathBuf;
12use std::process::Stdio;
13use std::sync::Arc;
14
15pub fn dir() -> PathBuf {
16    crate::settings::Settings::cache_dir().join("proxies")
17}
18
19/// Where the proxy for `src` at `height` lives (whether or not it has been built yet).
20pub fn proxy_path(src: &str, height: u32) -> PathBuf {
21    use std::hash::{Hash, Hasher};
22    let mtime = std::fs::metadata(src)
23        .and_then(|m| m.modified())
24        .ok()
25        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
26        .map(|d| d.as_secs())
27        .unwrap_or(0);
28    let mut h = std::collections::hash_map::DefaultHasher::new();
29    (src.to_ascii_lowercase(), mtime, height).hash(&mut h);
30    dir().join(format!("{:016x}.mp4", h.finish()))
31}
32
33/// Transcode `src` into its proxy file on a background thread (temp + rename; the destination never
34/// exists half-written). Video only — audio always plays from the original.
35pub fn generate(src: String, dst: PathBuf, height: u32) -> Arc<Progress> {
36    export::spawn_job("proxy", move |prog| run(&src, &dst, height, prog))
37}
38
39fn run(src: &str, dst: &PathBuf, height: u32, prog: &Progress) -> Result<(), String> {
40    let ffmpeg = ffpipe::ffmpeg_exe().ok_or("ffmpeg.exe not found")?;
41    std::fs::create_dir_all(dir()).map_err(|e| format!("proxies dir: {e}"))?;
42    let dur = crate::engine::convert::probe_seconds(std::path::Path::new(src)).unwrap_or(0.0);
43    let tmp = export::temp_output(dst);
44    let mut cmd = ffpipe::command(&ffmpeg);
45    cmd.args(["-y", "-hide_banner", "-loglevel", "error", "-progress", "pipe:1"]);
46    cmd.arg("-i").arg(src);
47    // -2 keeps aspect at an even width; -g 1 = all-intra; no audio (the original supplies it)
48    let vf = format!("scale=-2:{}", height.max(120));
49    cmd.args(["-vf", &vf, "-c:v", "libx264", "-preset", "veryfast", "-g", "1", "-crf", "20"]);
50    cmd.args(["-pix_fmt", "yuv420p", "-an", "-movflags", "+faststart"]);
51    cmd.arg(&tmp.0);
52    cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
53    let mut child = cmd.spawn().map_err(|e| format!("ffmpeg: {e}"))?;
54    let tail = export::stderr_tail(&mut child);
55    if let Some(out) = child.stdout.take() {
56        prog.set(0.0, "Building proxy…");
57        for line in std::io::BufReader::new(out).lines() {
58            let Ok(line) = line else { break };
59            if prog.is_cancelled() {
60                let _ = child.kill();
61                break;
62            }
63            if let Some(us) = line.strip_prefix("out_time_us=").and_then(|v| v.trim().parse::<f64>().ok()) {
64                let f = if dur > 0.0 { (us / 1e6 / dur).clamp(0.0, 1.0).min(0.99) as f32 } else { 0.0 };
65                prog.set(f, "Building proxy…");
66            }
67        }
68    }
69    export::wait_ffmpeg(&mut child, tail, prog)?;
70    tmp.commit(dst.as_path())
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    /// The proxy name is stable for the same (path, mtime, height) and changes with any of them.
78    #[test]
79    fn proxy_path_is_deterministic() {
80        let a = proxy_path("C:\\missing\\clip.mp4", 720);
81        assert_eq!(a, proxy_path("C:\\missing\\clip.mp4", 720));
82        assert_eq!(a, proxy_path("C:\\MISSING\\CLIP.mp4", 720), "case-insensitive paths hash alike");
83        assert_ne!(a, proxy_path("C:\\missing\\clip.mp4", 540));
84        assert_ne!(a, proxy_path("C:\\missing\\other.mp4", 720));
85        assert!(a.extension().is_some_and(|e| e == "mp4"));
86    }
87}