simple_editor\engine/
convert.rs

1//! "Convert To…": transcode a media file to another container/format with ffmpeg (no compositor):
2//! video ↔ gif, mp4 ↔ mov/mkv/webm, audio extraction (mp3/wav/m4a/flac), optional rescale with a chosen
3//! scaler. Runs on a background thread, reports through engine::export::Progress, never writes the
4//! destination until done (temp + rename, same as exports), never touches the source.
5
6use crate::engine::export::{self, codec_args, detect_encoders, Progress, AUDIO_EXTS};
7use crate::media::ffpipe;
8use std::io::BufRead;
9use std::path::PathBuf;
10use std::process::Stdio;
11use std::sync::Arc;
12
13#[derive(Clone, Debug)]
14pub struct ConvertOptions {
15    pub src: PathBuf,
16    pub out: PathBuf,
17    /// "auto" or an ffmpeg encoder name (see Settings.encoder / export::codec_args).
18    pub encoder: String,
19    pub crf: u32,
20    pub preset: String,
21    /// Rescale to this size (None = keep), with ffmpeg flags `scaler` ("neighbor" | "bilinear" | "bicubic"
22    /// | "lanczos" | "area" | "spline").
23    pub out_size: Option<(u32, u32)>,
24    pub scaler: String,
25    /// GIF outputs: frame rate (default 15) and palette generation for quality.
26    pub gif_fps: u32,
27    /// Compression target in bytes. Some = bitrate mode: the video bitrate is derived from the source
28    /// duration (minus a fixed audio allowance) and CRF is ignored. None = quality (CRF) mode.
29    pub target_bytes: Option<u64>,
30}
31
32/// Audio allowance subtracted from a size target, bits per second. Matches `codec_args`' AAC default.
33const AUDIO_BPS: f64 = 128_000.0;
34
35/// Video bitrate (bits/s) that lands `target` bytes over `dur` seconds, leaving room for the audio.
36/// None when the numbers make no sense (no duration, or the target is smaller than the audio track).
37pub fn target_bitrate(target: u64, dur: f64) -> Option<u32> {
38    if dur <= 0.0 || target == 0 {
39        return None;
40    }
41    // 2 % container overhead: muxing is never free, and overshooting the target is the failure people notice
42    let total = target as f64 * 8.0 * 0.98 / dur;
43    let v = total - AUDIO_BPS;
44    (v >= 32_000.0).then(|| v as u32)
45}
46
47/// Start a conversion; progress 0..1 by parsing ffmpeg `-progress pipe:1` (out_time_us) against the
48/// input duration from ffprobe. `codec_args(ext, ...)` from export.rs picks the codecs; gif gets
49/// `fps=<gif_fps>[,scale=...:flags=<scaler>],split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`.
50pub fn start_convert(opts: ConvertOptions) -> Arc<Progress> {
51    export::spawn_job("convert", move |prog| run_convert(&opts, prog))
52}
53
54fn run_convert(opts: &ConvertOptions, prog: &Progress) -> Result<(), String> {
55    let ffmpeg = ffpipe::ffmpeg_exe().ok_or("ffmpeg.exe not found")?;
56    let ext = export::ext_of(&opts.out);
57    let tmp = export::temp_output(&opts.out);
58    let dur = probe_duration(&opts.src).unwrap_or(0.0);
59    let scaler = if opts.scaler.is_empty() { "lanczos" } else { &opts.scaler };
60    let scale = opts.out_size.map(|(w, h)| format!("scale={w}:{h}:flags={scaler}"));
61
62    let mut cmd = ffpipe::command(&ffmpeg);
63    cmd.args(["-y", "-hide_banner", "-loglevel", "error", "-progress", "pipe:1"]);
64    cmd.arg("-i").arg(&opts.src);
65    if ext == "gif" {
66        let fps = opts.gif_fps.max(1);
67        let mut chain = format!("fps={fps}");
68        if let Some(s) = &scale {
69            chain.push(',');
70            chain.push_str(s);
71        }
72        chain.push_str(",split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse");
73        cmd.args(["-filter_complex", &chain, "-an"]);
74    } else if matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "bmp" | "webp") {
75        if let Some(s) = &scale {
76            cmd.args(["-vf", s]);
77        }
78        cmd.args(["-frames:v", "1", "-update", "1"]);
79    } else if AUDIO_EXTS.contains(&ext.as_str()) {
80        cmd.arg("-vn");
81        cmd.args(codec_args(&ext, &opts.encoder, opts.crf, &opts.preset, &detect_encoders()));
82    } else {
83        let mut args = codec_args(&ext, &opts.encoder, opts.crf, &opts.preset, &detect_encoders());
84        if let Some(bps) = opts.target_bytes.and_then(|t| target_bitrate(t, dur)) {
85            // bitrate mode: drop the quality knob the codec args set and cap the rate instead
86            strip_quality(&mut args);
87            let (b, buf) = (format!("{bps}"), format!("{}", bps * 2));
88            args.extend(["-b:v".into(), b.clone(), "-maxrate".into(), b, "-bufsize".into(), buf]);
89        }
90        let mut vf = scale.unwrap_or_default();
91        if args.iter().any(|a| a == "yuv420p" || a == "nv12") {
92            // even-size guard (no-op on even input): the source size is not probed here
93            if !vf.is_empty() {
94                vf.push(',');
95            }
96            vf.push_str("pad=ceil(iw/2)*2:ceil(ih/2)*2");
97        }
98        if !vf.is_empty() {
99            cmd.args(["-vf", &vf]);
100        }
101        // keep every audio stream — ffmpeg's default stream selection would keep only one
102        cmd.args(["-map", "0:v:0?", "-map", "0:a?"]);
103        cmd.args(args);
104    }
105    cmd.arg(&tmp.0);
106    cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
107    let mut child = cmd.spawn().map_err(|e| format!("ffmpeg: {e}"))?;
108    let tail = export::stderr_tail(&mut child);
109
110    // read -progress key=value lines from stdout until EOF (cancel kills ffmpeg → EOF)
111    if let Some(out) = child.stdout.take() {
112        prog.set(0.0, "Converting…");
113        for line in std::io::BufReader::new(out).lines() {
114            let Ok(line) = line else { break };
115            if prog.is_cancelled() {
116                let _ = child.kill();
117                break;
118            }
119            if let Some(us) = line.strip_prefix("out_time_us=").and_then(|v| v.trim().parse::<f64>().ok()) {
120                let t = us / 1e6;
121                if dur > 0.0 {
122                    prog.set((t / dur).clamp(0.0, 1.0).min(0.99) as f32, format!("Converting {t:.1} / {dur:.1} s"));
123                } else {
124                    prog.set(0.0, format!("Converting {t:.1} s"));
125                }
126            }
127        }
128    }
129    export::wait_ffmpeg(&mut child, tail, prog)?; // returns Err(CANCELLED) when cancelled
130    tmp.commit(&opts.out)
131}
132
133/// Remove `-crf N` / `-qp N` / `-cq N` pairs so an explicit bitrate is not fighting a quality target.
134fn strip_quality(args: &mut Vec<String>) {
135    let mut i = 0;
136    while i < args.len() {
137        if matches!(args[i].as_str(), "-crf" | "-qp" | "-cq" | "-global_quality") && i + 1 < args.len() {
138            args.drain(i..i + 2);
139        } else {
140            i += 1;
141        }
142    }
143}
144
145#[cfg(test)]
146mod target_tests {
147    use super::*;
148
149    /// A size target becomes a video bitrate that leaves room for the audio, and refuses impossible asks.
150    #[test]
151    fn bitrate_from_a_size_target() {
152        // 10 MB over 60 s = 1.307 Mbit/s total, minus 128 kbit/s of audio
153        let bps = target_bitrate(10_000_000, 60.0).unwrap();
154        assert!((1_170_000..=1_190_000).contains(&bps), "{bps}");
155        // round trip: the encoded video plus the audio allowance lands within 2 % of the target
156        let bytes = (bps as f64 + 128_000.0) * 60.0 / 8.0;
157        assert!((bytes - 10_000_000.0).abs() < 10_000_000.0 * 0.03, "{bytes}");
158        assert_eq!(target_bitrate(10_000_000, 0.0), None, "no duration, no bitrate");
159        assert_eq!(target_bitrate(1_000, 60.0), None, "smaller than the audio track");
160        // the bitrate override wins over the codec's quality knob
161        let mut args: Vec<String> =
162            ["-c:v", "libx264", "-crf", "23", "-pix_fmt", "yuv420p"].iter().map(|s| s.to_string()).collect();
163        strip_quality(&mut args);
164        assert!(!args.iter().any(|a| a == "-crf" || a == "23"), "{args:?}");
165        assert!(args.iter().any(|a| a == "libx264"));
166    }
167}
168
169/// Container duration in seconds via ffprobe (used by the Compress window to size a bitrate).
170pub fn probe_seconds(path: &std::path::Path) -> Option<f64> {
171    probe_duration(path)
172}
173
174/// Container duration in seconds via ffprobe.
175fn probe_duration(path: &std::path::Path) -> Option<f64> {
176    let o = ffpipe::command(&ffpipe::ffprobe_exe()?)
177        .args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0"])
178        .arg(path)
179        .stdin(Stdio::null())
180        .output()
181        .ok()?;
182    String::from_utf8_lossy(&o.stdout).trim().parse().ok()
183}
184
185/// Output extensions offered by the converter UI.
186pub const TARGETS: &[&str] = &["mp4", "mov", "mkv", "webm", "gif", "avi", "mp3", "wav", "m4a", "flac", "png", "jpg"];
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::engine::export::tests::{gen_media, temp_dir, wait_done};
192
193    fn opts(src: &std::path::Path, out: PathBuf) -> ConvertOptions {
194        ConvertOptions {
195            src: src.to_path_buf(),
196            out,
197            encoder: "auto".into(),
198            crf: 30,
199            preset: "ultrafast".into(),
200            out_size: None,
201            scaler: "bicubic".into(),
202            gif_fps: 12,
203            target_bytes: None,
204        }
205    }
206
207    fn probe(path: &std::path::Path, entries: &str) -> String {
208        let o = ffpipe::command(&ffpipe::ffprobe_exe().unwrap())
209            .args(["-v", "error", "-show_entries", entries, "-of", "csv=p=0"])
210            .arg(path)
211            .output()
212            .unwrap();
213        String::from_utf8_lossy(&o.stdout).trim().to_string()
214    }
215
216    #[test]
217    fn convert_real() {
218        let dir = temp_dir("convert");
219        let Some(src) = gen_media(&dir) else {
220            eprintln!("ffmpeg missing — skipped");
221            return;
222        };
223        // → gif (palette chain)
224        let gif = dir.join("out.gif");
225        assert_eq!(wait_done(&start_convert(opts(&src, gif.clone()))), None);
226        assert!(gif.exists());
227        assert_eq!(probe(&gif, "format=format_name"), "gif");
228        // → mp3, duration ≈ 4 s
229        let mp3 = dir.join("out.mp3");
230        assert_eq!(wait_done(&start_convert(opts(&src, mp3.clone()))), None);
231        let d: f64 = probe(&mp3, "format=duration").parse().expect("duration");
232        assert!((d - 4.0).abs() < 0.3, "{d}");
233        // → mp4 rescaled to 160x120 with bicubic
234        let mp4 = dir.join("small.mp4");
235        let o = ConvertOptions { out_size: Some((160, 120)), ..opts(&src, mp4.clone()) };
236        let prog = start_convert(o);
237        assert_eq!(wait_done(&prog), None);
238        assert!(prog.fraction() > 0.5, "{}", prog.fraction());
239        assert_eq!(probe(&mp4, "stream=width,height").lines().next(), Some("160,120"));
240        // both source audio streams survive (default stream selection would keep one)
241        let audio = probe(&mp4, "stream=codec_type").lines().filter(|l| *l == "audio").count();
242        assert_eq!(audio, 2, "audio streams kept");
243        // failure (bad source) reports an error and leaves no output
244        let bad = dir.join("bad.mp4");
245        assert!(wait_done(&start_convert(opts(&dir.join("missing.mp4"), bad.clone()))).is_some());
246        assert!(!bad.exists());
247        let _ = std::fs::remove_dir_all(&dir);
248    }
249}