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, ¶ms)?;
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}