Skip to main content

ff_preview/proxy/
mod.rs

1//! Proxy file generation for ff-preview.
2//!
3//! This module is only compiled when the `proxy` feature is enabled.
4//! It provides [`ProxyGenerator`] for generating lower-resolution proxy files
5//! from original media using [`ff_pipeline::Pipeline`] internally.
6
7use 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// ── ProxyResolution ───────────────────────────────────────────────────────────
18
19/// Output resolution for a proxy file, expressed as a fraction of the source.
20///
21/// The target dimensions are computed as `(src / divisor) & !1` — divided by
22/// the factor and rounded down to the nearest even number so that video codecs
23/// do not reject odd dimensions.
24///
25/// | Variant   | Divisor | 1920×1080 → |
26/// |-----------|---------|-------------|
27/// | `Half`    | 2       | 960×540     |
28/// | `Quarter` | 4       | 480×270     |
29/// | `Eighth`  | 8       | 240×136     |
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ProxyResolution {
32    /// 1/2 of the original dimensions (e.g. 1920×1080 → 960×540).
33    Half,
34    /// 1/4 of the original dimensions (e.g. 1920×1080 → 480×270).
35    Quarter,
36    /// 1/8 of the original dimensions (e.g. 1920×1080 → 240×136).
37    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
58// ── ProxyJob ──────────────────────────────────────────────────────────────────
59
60/// A handle to a running background proxy generation job.
61///
62/// Created by [`ProxyGenerator::generate_async`]. Use
63/// [`progress`](Self::progress) for non-blocking progress polling and
64/// [`wait`](Self::wait) to block until the job completes.
65pub struct ProxyJob {
66    handle: std::thread::JoinHandle<Result<PathBuf, PreviewError>>,
67    /// Stores progress as thousandths (0–1000) so it can be read from any
68    /// thread without a lock. Updated by the background thread's progress
69    /// callback on each encoded frame.
70    progress: Arc<AtomicU32>,
71}
72
73impl ProxyJob {
74    /// Current progress in the range `0.0..=1.0`.
75    ///
76    /// Reads an `AtomicU32` — non-blocking and safe to call from any thread.
77    /// Returns `0.0` when the source container does not report a frame count
78    /// or before the first frame is encoded.
79    #[must_use]
80    pub fn progress(&self) -> f64 {
81        f64::from(self.progress.load(Ordering::Relaxed)) / 1000.0
82    }
83
84    /// Returns `true` if the background thread has finished (success or error).
85    ///
86    /// Non-blocking — does not consume the job.
87    #[must_use]
88    pub fn is_done(&self) -> bool {
89        self.handle.is_finished()
90    }
91
92    /// Block until proxy generation completes and return the output path.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`PreviewError`] if proxy generation failed or if the background
97    /// thread panicked (surfaced as `PreviewError::Ffmpeg { code: 0 }`).
98    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
108// ── ProxyGenerator ────────────────────────────────────────────────────────────
109
110/// Generates a lower-resolution proxy file from an original media file.
111///
112/// Proxy files allow smooth real-time playback of high-resolution footage by
113/// substituting a lower-quality copy during editing. Uses
114/// [`ff_pipeline::Pipeline`] internally — no raw `FFmpeg` calls.
115///
116/// # Usage
117///
118/// ```ignore
119/// let output = ProxyGenerator::new(Path::new("4k_clip.mp4"))?
120///     .resolution(ProxyResolution::Half)
121///     .output_dir(Path::new("/tmp/proxies"))
122///     .generate()?;
123/// ```
124///
125/// # Output path
126///
127/// `{output_dir}/{stem}_proxy_{half|quarter|eighth}.mp4`
128pub struct ProxyGenerator {
129    input: PathBuf,
130    resolution: ProxyResolution,
131    codec: VideoCodec,
132    output_dir: Option<PathBuf>,
133}
134
135impl ProxyGenerator {
136    /// Open the input file and prepare for proxy generation.
137    ///
138    /// Probes `input` to confirm it is a valid media file with a video stream.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`PreviewError`] if the file cannot be probed.
143    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    /// Set the output resolution (default: [`ProxyResolution::Half`]).
154    #[must_use]
155    pub fn resolution(self, res: ProxyResolution) -> Self {
156        Self {
157            resolution: res,
158            ..self
159        }
160    }
161
162    /// Set the output video codec (default: [`VideoCodec::H264`]).
163    #[must_use]
164    pub fn codec(self, codec: VideoCodec) -> Self {
165        Self { codec, ..self }
166    }
167
168    /// Set the output directory (default: same directory as the input file).
169    #[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    /// Generate the proxy file synchronously.
178    ///
179    /// Returns the path of the generated proxy file on success.
180    ///
181    /// Dimensions are source ÷ resolution factor, rounded down to the nearest
182    /// even number. Default quality: H.264 CRF 23, AAC audio.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`PreviewError`] if probing, filtering, or encoding fails.
187    pub fn generate(self) -> Result<PathBuf, PreviewError> {
188        self.generate_with_callback(|_| true)
189    }
190
191    /// Start proxy generation on a background thread and return immediately.
192    ///
193    /// The returned [`ProxyJob`] lets you poll progress with
194    /// [`ProxyJob::progress`] or block until completion with
195    /// [`ProxyJob::wait`].
196    ///
197    /// Progress is tracked via `ff-pipeline`'s progress callback: each encoded
198    /// frame updates an `AtomicU32` (thousandths of completion, 0–1000). When
199    /// the source container does not report a total frame count, progress stays
200    /// at `0.0` throughout the run.
201    #[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                        // raw is in 0..=1000 after the saturating division — fits in u32.
213                        u32::try_from(raw.min(1000)).unwrap_or(1000)
214                    }
215                });
216                progress_clone.store(v, Ordering::Relaxed);
217                true // always continue; cancellation is not supported
218            })
219        });
220        ProxyJob { handle, progress }
221    }
222
223    /// Shared pipeline setup used by both [`generate`](Self::generate) and
224    /// [`generate_async`](Self::generate_async).
225    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        // Round down to the nearest even number so codecs don't reject odd dimensions.
239        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        // TODO(#385): EncoderConfig has no preset field; add preset=fast when supported.
268        // FilterGraph::build() returns FilterError; convert via PipelineError since
269        // PreviewError only wraps PipelineError (not FilterError directly).
270        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            // Defaults: CRF 23, AAC audio — matches issue spec.
278            .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        // 1079 / 2 = 539 → & !1 = 538 (rounded down to even)
320        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        // Even input stays even.
326        let even: u32 = 1080;
327        let result_even = (even / 2) & !1;
328        assert_eq!(result_even, 540);
329
330        // 1/8 of 1920 = 240 (already even).
331        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        // The internal atomic stores thousandths (0–1000).
344        // Verify the scaling formula: raw / 1000.0 = fraction.
345        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}