1use 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 pub encoder: String,
19 pub crf: u32,
20 pub preset: String,
21 pub out_size: Option<(u32, u32)>,
24 pub scaler: String,
25 pub gif_fps: u32,
27 pub target_bytes: Option<u64>,
30}
31
32const AUDIO_BPS: f64 = 128_000.0;
34
35pub fn target_bitrate(target: u64, dur: f64) -> Option<u32> {
38 if dur <= 0.0 || target == 0 {
39 return None;
40 }
41 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
47pub 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 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 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 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 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)?; tmp.commit(&opts.out)
131}
132
133fn 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 #[test]
151 fn bitrate_from_a_size_target() {
152 let bps = target_bitrate(10_000_000, 60.0).unwrap();
154 assert!((1_170_000..=1_190_000).contains(&bps), "{bps}");
155 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 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
169pub fn probe_seconds(path: &std::path::Path) -> Option<f64> {
171 probe_duration(path)
172}
173
174fn 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
185pub 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 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 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 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 let audio = probe(&mp4, "stream=codec_type").lines().filter(|l| *l == "audio").count();
242 assert_eq!(audio, 2, "audio streams kept");
243 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}