use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use ff_filter::{FilterGraph, ScaleAlgorithm};
use ff_format::VideoCodec;
use ff_pipeline::{EncoderConfig, Pipeline, Progress};
use crate::error::PreviewError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProxyResolution {
Half,
Quarter,
Eighth,
}
impl ProxyResolution {
fn divisor(self) -> u32 {
match self {
Self::Half => 2,
Self::Quarter => 4,
Self::Eighth => 8,
}
}
fn suffix(self) -> &'static str {
match self {
Self::Half => "half",
Self::Quarter => "quarter",
Self::Eighth => "eighth",
}
}
}
pub struct ProxyJob {
handle: std::thread::JoinHandle<Result<PathBuf, PreviewError>>,
progress: Arc<AtomicU32>,
}
impl ProxyJob {
#[must_use]
pub fn progress(&self) -> f64 {
f64::from(self.progress.load(Ordering::Relaxed)) / 1000.0
}
#[must_use]
pub fn is_done(&self) -> bool {
self.handle.is_finished()
}
pub fn wait(self) -> Result<PathBuf, PreviewError> {
self.handle.join().unwrap_or_else(|_| {
Err(PreviewError::Ffmpeg {
code: 0,
message: "proxy thread panicked".to_string(),
})
})
}
}
pub struct ProxyGenerator {
input: PathBuf,
resolution: ProxyResolution,
codec: VideoCodec,
output_dir: Option<PathBuf>,
}
impl ProxyGenerator {
pub fn new(input: &Path) -> Result<Self, PreviewError> {
ff_probe::open(input)?;
Ok(Self {
input: input.to_path_buf(),
resolution: ProxyResolution::Half,
codec: VideoCodec::H264,
output_dir: None,
})
}
#[must_use]
pub fn resolution(self, res: ProxyResolution) -> Self {
Self {
resolution: res,
..self
}
}
#[must_use]
pub fn codec(self, codec: VideoCodec) -> Self {
Self { codec, ..self }
}
#[must_use]
pub fn output_dir(self, dir: &Path) -> Self {
Self {
output_dir: Some(dir.to_path_buf()),
..self
}
}
pub fn generate(self) -> Result<PathBuf, PreviewError> {
self.generate_with_callback(|_| true)
}
#[must_use]
pub fn generate_async(self) -> ProxyJob {
let progress = Arc::new(AtomicU32::new(0));
let progress_clone = Arc::clone(&progress);
let handle = std::thread::spawn(move || {
self.generate_with_callback(move |p: &Progress| {
let v = p.total_frames.map_or(0u32, |total| {
if total == 0 {
0
} else {
let raw = p.frames_processed.saturating_mul(1000) / total;
u32::try_from(raw.min(1000)).unwrap_or(1000)
}
});
progress_clone.store(v, Ordering::Relaxed);
true })
});
ProxyJob { handle, progress }
}
fn generate_with_callback<F>(self, callback: F) -> Result<PathBuf, PreviewError>
where
F: Fn(&Progress) -> bool + Send + 'static,
{
let info = ff_probe::open(&self.input)?;
let (src_w, src_h) = info
.resolution()
.ok_or_else(|| PreviewError::NoVideoStream {
path: self.input.clone(),
})?;
let divisor = self.resolution.divisor();
let dst_w = (src_w / divisor) & !1;
let dst_h = (src_h / divisor) & !1;
let output_dir = self
.output_dir
.as_deref()
.or_else(|| self.input.parent())
.unwrap_or_else(|| Path::new("."));
let stem = self
.input
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("output");
let filename = format!("{stem}_proxy_{}.mp4", self.resolution.suffix());
let output_path = output_dir.join(&filename);
log::debug!(
"generating proxy input={} output={} src={}x{} dst={}x{}",
self.input.display(),
output_path.display(),
src_w,
src_h,
dst_w,
dst_h
);
let filter = FilterGraph::builder()
.scale(dst_w, dst_h, ScaleAlgorithm::Fast)
.build()
.map_err(ff_pipeline::PipelineError::from)?;
let config = EncoderConfig::builder()
.video_codec(self.codec)
.build();
let input_str = self.input.to_string_lossy();
let output_str = output_path.to_string_lossy();
Pipeline::builder()
.input(input_str.as_ref())
.filter(filter)
.output(output_str.as_ref(), config)
.on_progress(callback)
.build()?
.run()?;
Ok(output_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn proxy_resolution_half_should_have_divisor_2() {
assert_eq!(ProxyResolution::Half.divisor(), 2);
assert_eq!(ProxyResolution::Half.suffix(), "half");
}
#[test]
fn proxy_resolution_quarter_should_have_divisor_4() {
assert_eq!(ProxyResolution::Quarter.divisor(), 4);
assert_eq!(ProxyResolution::Quarter.suffix(), "quarter");
}
#[test]
fn proxy_resolution_eighth_should_have_divisor_8() {
assert_eq!(ProxyResolution::Eighth.divisor(), 8);
assert_eq!(ProxyResolution::Eighth.suffix(), "eighth");
}
#[test]
fn proxy_resolution_dimension_should_round_to_even() {
let odd: u32 = 1079;
let result = (odd / 2) & !1;
assert_eq!(result, 538, "odd dimension must be rounded down to even");
assert_eq!(result % 2, 0, "result must be even");
let even: u32 = 1080;
let result_even = (even / 2) & !1;
assert_eq!(result_even, 540);
let result_eighth = (1920_u32 / 8) & !1;
assert_eq!(result_eighth, 240);
}
#[test]
fn proxy_generator_new_should_fail_for_nonexistent_file() {
let result = ProxyGenerator::new(Path::new("nonexistent_proxy_test.mp4"));
assert!(result.is_err(), "new() must fail for a non-existent file");
}
#[test]
fn proxy_job_progress_scaling_should_convert_thousandths_to_fraction() {
for (raw, expected) in [(0u32, 0.0f64), (500, 0.5), (1000, 1.0), (250, 0.25)] {
let frac = f64::from(raw) / 1000.0;
assert!(
(frac - expected).abs() < f64::EPSILON,
"raw={raw} expected={expected} got={frac}"
);
}
}
#[test]
#[ignore = "requires FFmpeg and assets/video/gameplay.mp4; run with -- --include-ignored"]
fn proxy_generate_async_should_complete_and_produce_output_file() {
let input = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../assets/video/gameplay.mp4");
if !input.exists() {
println!("skipping: gameplay.mp4 not found");
return;
}
let tmp = std::env::temp_dir();
let job = match ProxyGenerator::new(&input) {
Ok(g) => g
.resolution(ProxyResolution::Quarter)
.output_dir(&tmp)
.generate_async(),
Err(e) => {
println!("skipping: {e}");
return;
}
};
match job.wait() {
Ok(path) => {
assert!(path.exists(), "proxy output file must exist");
assert!(
path.to_str()
.map(|s| s.contains("_proxy_quarter"))
.unwrap_or(false),
"output path must contain '_proxy_quarter'"
);
let _ = std::fs::remove_file(&path);
}
Err(e) => println!("skipping: generate_async failed: {e}"),
}
}
#[test]
#[ignore = "requires FFmpeg and assets/video/gameplay.mp4; run with -- --include-ignored"]
fn proxy_generator_half_resolution_should_produce_output_file() {
let input = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../assets/video/gameplay.mp4");
if !input.exists() {
println!("skipping: gameplay.mp4 not found");
return;
}
let tmp = std::env::temp_dir();
let result = ProxyGenerator::new(&input)
.unwrap()
.resolution(ProxyResolution::Half)
.output_dir(&tmp)
.generate();
match result {
Ok(path) => {
assert!(path.exists(), "proxy output file must exist");
assert!(
path.to_str()
.map(|s| s.contains("_proxy_half"))
.unwrap_or(false),
"output path must contain '_proxy_half'"
);
let _ = std::fs::remove_file(&path);
}
Err(e) => println!("skipping: proxy generation failed: {e}"),
}
}
}