Skip to main content

clippr/
lib.rs

1pub mod encode;
2pub mod error;
3pub mod gui;
4pub mod probe;
5pub mod strategy;
6
7use encode::EncodeParams;
8use error::{Error, Result};
9use std::collections::VecDeque;
10use std::path::{Path, PathBuf};
11use strategy::InitialParams;
12
13const MIN_SPLIT_DURATION: f64 = 0.5;
14
15pub struct ConvertOptions {
16    pub input: PathBuf,
17    pub output: Option<PathBuf>,
18    pub max_size_mb: f64,
19    pub width: u32,
20    pub fps: u32,
21    pub colors: u32,
22    pub chunk_secs: f64,
23}
24
25#[derive(Clone)]
26struct Segment {
27    start_secs: f64,
28    duration_secs: f64,
29}
30
31fn output_stem_from_args(input: &Path, output: Option<&Path>) -> Result<PathBuf> {
32    match output {
33        Some(path) => Ok(path.to_path_buf().with_extension("")),
34        None => {
35            let stem = input
36                .file_stem()
37                .ok_or_else(|| Error::InvalidInput("input has no file stem".into()))?;
38            Ok(input.with_file_name(stem))
39        }
40    }
41}
42
43fn chunk_output_path(stem: &Path, chunk_index: u32, chunk_count: u32) -> PathBuf {
44    if chunk_count == 1 {
45        stem.with_extension("gif")
46    } else {
47        let name = format!(
48            "{}_{:03}.gif",
49            stem.file_name().unwrap_or_default().to_string_lossy(),
50            chunk_index + 1,
51        );
52        stem.with_file_name(name)
53    }
54}
55
56fn temp_output_path(stem: &Path, index: u32) -> PathBuf {
57    let name = format!(
58        "{}.tmp_{:06}.gif",
59        stem.file_name().unwrap_or_default().to_string_lossy(),
60        index,
61    );
62    stem.with_file_name(name)
63}
64
65pub fn convert(
66    options: &ConvertOptions,
67    mut on_progress: impl FnMut(&str),
68) -> Result<Vec<PathBuf>> {
69    if !options.input.exists() {
70        return Err(Error::InputNotFound(options.input.clone()));
71    }
72
73    if options.max_size_mb <= 0.0 {
74        return Err(Error::InvalidInput("--max-size-mb must be positive".into()));
75    }
76
77    if options.chunk_secs <= 0.0 {
78        return Err(Error::InvalidInput("--chunk-secs must be positive".into()));
79    }
80
81    let info = probe::probe(&options.input)?;
82    on_progress(&format!(
83        "input: {}x{}, {:.1}fps, {:.1}s",
84        info.width, info.height, info.framerate, info.duration_secs
85    ));
86
87    let target_bytes = (options.max_size_mb * 1024.0 * 1024.0) as u64;
88    let output_stem = output_stem_from_args(&options.input, options.output.as_deref())?;
89    let initial_chunk_count = (info.duration_secs / options.chunk_secs).ceil() as u32;
90
91    if initial_chunk_count == 0 {
92        return Err(Error::InvalidInput("video has zero duration".into()));
93    }
94
95    let initial = InitialParams {
96        width: options.width.min(info.width),
97        fps: options.fps.min(info.framerate.ceil() as u32),
98        colors: options.colors,
99    };
100
101    let mut queue: VecDeque<Segment> = VecDeque::new();
102    for chunk_index in 0..initial_chunk_count {
103        let start_secs = chunk_index as f64 * options.chunk_secs;
104        let remaining = info.duration_secs - start_secs;
105        let duration_secs = remaining.min(options.chunk_secs);
106        if duration_secs > 0.0 {
107            queue.push_back(Segment {
108                start_secs,
109                duration_secs,
110            });
111        }
112    }
113
114    let mut temp_paths: Vec<PathBuf> = Vec::new();
115    let mut temp_counter: u32 = 0;
116
117    while let Some(segment) = queue.pop_front() {
118        let temp_path = temp_output_path(&output_stem, temp_counter);
119        temp_counter += 1;
120
121        on_progress(&format!(
122            "\nsegment: {:.1}s - {:.1}s ({:.1}s)",
123            segment.start_secs,
124            segment.start_secs + segment.duration_secs,
125            segment.duration_secs,
126        ));
127
128        let params = EncodeParams {
129            width: initial.width,
130            fps: initial.fps,
131            colors: initial.colors,
132            start_secs: segment.start_secs,
133            duration_secs: segment.duration_secs,
134        };
135
136        let size = encode::encode(&options.input, &temp_path, &params)?;
137
138        if size <= target_bytes {
139            let size_mb = size as f64 / (1024.0 * 1024.0);
140            on_progress(&format!("  -> {:.2} MB (fits at full quality)", size_mb));
141            temp_paths.push(temp_path);
142            continue;
143        }
144
145        std::fs::remove_file(&temp_path)?;
146
147        if segment.duration_secs > MIN_SPLIT_DURATION {
148            let half = segment.duration_secs / 2.0;
149            on_progress(&format!(
150                "  -> {:.2} MB (too large, splitting {:.1}s into 2x {:.1}s)",
151                size as f64 / (1024.0 * 1024.0),
152                segment.duration_secs,
153                half,
154            ));
155            queue.push_front(Segment {
156                start_secs: segment.start_secs + half,
157                duration_secs: segment.duration_secs - half,
158            });
159            queue.push_front(Segment {
160                start_secs: segment.start_secs,
161                duration_secs: half,
162            });
163            continue;
164        }
165
166        on_progress(&format!(
167            "  -> {:.2} MB (too large, segment too short to split — degrading quality)",
168            size as f64 / (1024.0 * 1024.0),
169        ));
170
171        let temp_path = temp_output_path(&output_stem, temp_counter);
172        temp_counter += 1;
173
174        let size = strategy::auto_encode(
175            &options.input,
176            &temp_path,
177            target_bytes,
178            &initial,
179            segment.start_secs,
180            segment.duration_secs,
181            &mut on_progress,
182        )?;
183
184        let size_mb = size as f64 / (1024.0 * 1024.0);
185        on_progress(&format!("  -> {:.2} MB (degraded quality)", size_mb));
186        temp_paths.push(temp_path);
187    }
188
189    let final_count = temp_paths.len() as u32;
190    let mut outputs: Vec<PathBuf> = Vec::new();
191
192    for (index, temp_path) in temp_paths.iter().enumerate() {
193        let final_path = chunk_output_path(&output_stem, index as u32, final_count);
194        std::fs::rename(temp_path, &final_path)?;
195        outputs.push(final_path);
196    }
197
198    on_progress(&format!("\ndone — {} chunk(s) written:", outputs.len()));
199    for path in &outputs {
200        on_progress(&format!("  {}", path.display()));
201    }
202
203    Ok(outputs)
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn single_chunk_produces_plain_gif_extension() {
212        let result = chunk_output_path(Path::new("demo"), 0, 1);
213        assert_eq!(result, PathBuf::from("demo.gif"));
214    }
215
216    #[test]
217    fn multi_chunk_produces_numbered_suffixes() {
218        let result = chunk_output_path(Path::new("demo"), 0, 4);
219        assert_eq!(result, PathBuf::from("demo_001.gif"));
220
221        let result = chunk_output_path(Path::new("demo"), 3, 4);
222        assert_eq!(result, PathBuf::from("demo_004.gif"));
223    }
224
225    #[test]
226    fn chunk_path_preserves_parent_directory() {
227        let stem = Path::new("/tmp/output/demo");
228        let result = chunk_output_path(stem, 0, 3);
229        assert_eq!(result, PathBuf::from("/tmp/output/demo_001.gif"));
230    }
231
232    #[test]
233    fn output_stem_strips_extension_from_input() {
234        let result = output_stem_from_args(Path::new("video.mp4"), None).unwrap();
235        assert_eq!(result, PathBuf::from("video"));
236    }
237
238    #[test]
239    fn output_stem_uses_explicit_output_without_extension() {
240        let result =
241            output_stem_from_args(Path::new("video.mp4"), Some(Path::new("out.gif"))).unwrap();
242        assert_eq!(result, PathBuf::from("out"));
243    }
244
245    #[test]
246    fn output_stem_explicit_output_no_extension() {
247        let result =
248            output_stem_from_args(Path::new("video.mp4"), Some(Path::new("myoutput"))).unwrap();
249        assert_eq!(result, PathBuf::from("myoutput"));
250    }
251}