1use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::sync::atomic::{AtomicU32, Ordering};
10
11use ff_filter::{FilterGraph, ScaleAlgorithm};
12use ff_format::VideoCodec;
13use ff_pipeline::{EncoderConfig, Pipeline, Progress};
14
15use crate::error::PreviewError;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ProxyResolution {
32 Half,
34 Quarter,
36 Eighth,
38}
39
40impl ProxyResolution {
41 fn divisor(self) -> u32 {
42 match self {
43 Self::Half => 2,
44 Self::Quarter => 4,
45 Self::Eighth => 8,
46 }
47 }
48
49 fn suffix(self) -> &'static str {
50 match self {
51 Self::Half => "half",
52 Self::Quarter => "quarter",
53 Self::Eighth => "eighth",
54 }
55 }
56}
57
58pub struct ProxyJob {
66 handle: std::thread::JoinHandle<Result<PathBuf, PreviewError>>,
67 progress: Arc<AtomicU32>,
71}
72
73impl ProxyJob {
74 #[must_use]
80 pub fn progress(&self) -> f64 {
81 f64::from(self.progress.load(Ordering::Relaxed)) / 1000.0
82 }
83
84 #[must_use]
88 pub fn is_done(&self) -> bool {
89 self.handle.is_finished()
90 }
91
92 pub fn wait(self) -> Result<PathBuf, PreviewError> {
99 self.handle.join().unwrap_or_else(|_| {
100 Err(PreviewError::Ffmpeg {
101 code: 0,
102 message: "proxy thread panicked".to_string(),
103 })
104 })
105 }
106}
107
108pub struct ProxyGenerator {
129 input: PathBuf,
130 resolution: ProxyResolution,
131 codec: VideoCodec,
132 output_dir: Option<PathBuf>,
133}
134
135impl ProxyGenerator {
136 pub fn new(input: &Path) -> Result<Self, PreviewError> {
144 ff_probe::open(input)?;
145 Ok(Self {
146 input: input.to_path_buf(),
147 resolution: ProxyResolution::Half,
148 codec: VideoCodec::H264,
149 output_dir: None,
150 })
151 }
152
153 #[must_use]
155 pub fn resolution(self, res: ProxyResolution) -> Self {
156 Self {
157 resolution: res,
158 ..self
159 }
160 }
161
162 #[must_use]
164 pub fn codec(self, codec: VideoCodec) -> Self {
165 Self { codec, ..self }
166 }
167
168 #[must_use]
170 pub fn output_dir(self, dir: &Path) -> Self {
171 Self {
172 output_dir: Some(dir.to_path_buf()),
173 ..self
174 }
175 }
176
177 pub fn generate(self) -> Result<PathBuf, PreviewError> {
188 self.generate_with_callback(|_| true)
189 }
190
191 #[must_use]
202 pub fn generate_async(self) -> ProxyJob {
203 let progress = Arc::new(AtomicU32::new(0));
204 let progress_clone = Arc::clone(&progress);
205 let handle = std::thread::spawn(move || {
206 self.generate_with_callback(move |p: &Progress| {
207 let v = p.total_frames.map_or(0u32, |total| {
208 if total == 0 {
209 0
210 } else {
211 let raw = p.frames_processed.saturating_mul(1000) / total;
212 u32::try_from(raw.min(1000)).unwrap_or(1000)
214 }
215 });
216 progress_clone.store(v, Ordering::Relaxed);
217 true })
219 });
220 ProxyJob { handle, progress }
221 }
222
223 fn generate_with_callback<F>(self, callback: F) -> Result<PathBuf, PreviewError>
226 where
227 F: Fn(&Progress) -> bool + Send + 'static,
228 {
229 let info = ff_probe::open(&self.input)?;
230
231 let (src_w, src_h) = info
232 .resolution()
233 .ok_or_else(|| PreviewError::NoVideoStream {
234 path: self.input.clone(),
235 })?;
236
237 let divisor = self.resolution.divisor();
238 let dst_w = (src_w / divisor) & !1;
240 let dst_h = (src_h / divisor) & !1;
241
242 let output_dir = self
243 .output_dir
244 .as_deref()
245 .or_else(|| self.input.parent())
246 .unwrap_or_else(|| Path::new("."));
247
248 let stem = self
249 .input
250 .file_stem()
251 .and_then(|s| s.to_str())
252 .unwrap_or("output");
253
254 let filename = format!("{stem}_proxy_{}.mp4", self.resolution.suffix());
255 let output_path = output_dir.join(&filename);
256
257 log::debug!(
258 "generating proxy input={} output={} src={}x{} dst={}x{}",
259 self.input.display(),
260 output_path.display(),
261 src_w,
262 src_h,
263 dst_w,
264 dst_h
265 );
266
267 let filter = FilterGraph::builder()
271 .scale(dst_w, dst_h, ScaleAlgorithm::Fast)
272 .build()
273 .map_err(ff_pipeline::PipelineError::from)?;
274
275 let config = EncoderConfig::builder()
276 .video_codec(self.codec)
277 .build();
279
280 let input_str = self.input.to_string_lossy();
281 let output_str = output_path.to_string_lossy();
282
283 Pipeline::builder()
284 .input(input_str.as_ref())
285 .filter(filter)
286 .output(output_str.as_ref(), config)
287 .on_progress(callback)
288 .build()?
289 .run()?;
290
291 Ok(output_path)
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn proxy_resolution_half_should_have_divisor_2() {
301 assert_eq!(ProxyResolution::Half.divisor(), 2);
302 assert_eq!(ProxyResolution::Half.suffix(), "half");
303 }
304
305 #[test]
306 fn proxy_resolution_quarter_should_have_divisor_4() {
307 assert_eq!(ProxyResolution::Quarter.divisor(), 4);
308 assert_eq!(ProxyResolution::Quarter.suffix(), "quarter");
309 }
310
311 #[test]
312 fn proxy_resolution_eighth_should_have_divisor_8() {
313 assert_eq!(ProxyResolution::Eighth.divisor(), 8);
314 assert_eq!(ProxyResolution::Eighth.suffix(), "eighth");
315 }
316
317 #[test]
318 fn proxy_resolution_dimension_should_round_to_even() {
319 let odd: u32 = 1079;
321 let result = (odd / 2) & !1;
322 assert_eq!(result, 538, "odd dimension must be rounded down to even");
323 assert_eq!(result % 2, 0, "result must be even");
324
325 let even: u32 = 1080;
327 let result_even = (even / 2) & !1;
328 assert_eq!(result_even, 540);
329
330 let result_eighth = (1920_u32 / 8) & !1;
332 assert_eq!(result_eighth, 240);
333 }
334
335 #[test]
336 fn proxy_generator_new_should_fail_for_nonexistent_file() {
337 let result = ProxyGenerator::new(Path::new("nonexistent_proxy_test.mp4"));
338 assert!(result.is_err(), "new() must fail for a non-existent file");
339 }
340
341 #[test]
342 fn proxy_job_progress_scaling_should_convert_thousandths_to_fraction() {
343 for (raw, expected) in [(0u32, 0.0f64), (500, 0.5), (1000, 1.0), (250, 0.25)] {
346 let frac = f64::from(raw) / 1000.0;
347 assert!(
348 (frac - expected).abs() < f64::EPSILON,
349 "raw={raw} expected={expected} got={frac}"
350 );
351 }
352 }
353
354 #[test]
355 #[ignore = "requires FFmpeg and assets/video/gameplay.mp4; run with -- --include-ignored"]
356 fn proxy_generate_async_should_complete_and_produce_output_file() {
357 let input = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
358 .join("../../assets/video/gameplay.mp4");
359 if !input.exists() {
360 println!("skipping: gameplay.mp4 not found");
361 return;
362 }
363 let tmp = std::env::temp_dir();
364 let job = match ProxyGenerator::new(&input) {
365 Ok(g) => g
366 .resolution(ProxyResolution::Quarter)
367 .output_dir(&tmp)
368 .generate_async(),
369 Err(e) => {
370 println!("skipping: {e}");
371 return;
372 }
373 };
374 match job.wait() {
375 Ok(path) => {
376 assert!(path.exists(), "proxy output file must exist");
377 assert!(
378 path.to_str()
379 .map(|s| s.contains("_proxy_quarter"))
380 .unwrap_or(false),
381 "output path must contain '_proxy_quarter'"
382 );
383 let _ = std::fs::remove_file(&path);
384 }
385 Err(e) => println!("skipping: generate_async failed: {e}"),
386 }
387 }
388
389 #[test]
390 #[ignore = "requires FFmpeg and assets/video/gameplay.mp4; run with -- --include-ignored"]
391 fn proxy_generator_half_resolution_should_produce_output_file() {
392 let input = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
393 .join("../../assets/video/gameplay.mp4");
394 if !input.exists() {
395 println!("skipping: gameplay.mp4 not found");
396 return;
397 }
398 let tmp = std::env::temp_dir();
399 let result = ProxyGenerator::new(&input)
400 .unwrap()
401 .resolution(ProxyResolution::Half)
402 .output_dir(&tmp)
403 .generate();
404 match result {
405 Ok(path) => {
406 assert!(path.exists(), "proxy output file must exist");
407 assert!(
408 path.to_str()
409 .map(|s| s.contains("_proxy_half"))
410 .unwrap_or(false),
411 "output path must contain '_proxy_half'"
412 );
413 let _ = std::fs::remove_file(&path);
414 }
415 Err(e) => println!("skipping: proxy generation failed: {e}"),
416 }
417 }
418}