1mod error;
10mod preview_inner;
11
12pub use error::PreviewImageError;
13
14use std::path::{Path, PathBuf};
15use std::time::Duration;
16
17pub struct SpriteSheet {
37 input: PathBuf,
38 cols: u32,
39 rows: u32,
40 frame_width: u32,
41 frame_height: u32,
42 output: PathBuf,
43}
44
45impl SpriteSheet {
46 pub fn new(input: impl AsRef<Path>) -> Self {
51 Self {
52 input: input.as_ref().to_path_buf(),
53 cols: 10,
54 rows: 10,
55 frame_width: 160,
56 frame_height: 90,
57 output: PathBuf::new(),
58 }
59 }
60
61 #[must_use]
63 pub fn cols(self, n: u32) -> Self {
64 Self { cols: n, ..self }
65 }
66
67 #[must_use]
69 pub fn rows(self, n: u32) -> Self {
70 Self { rows: n, ..self }
71 }
72
73 #[must_use]
75 pub fn frame_width(self, w: u32) -> Self {
76 Self {
77 frame_width: w,
78 ..self
79 }
80 }
81
82 #[must_use]
84 pub fn frame_height(self, h: u32) -> Self {
85 Self {
86 frame_height: h,
87 ..self
88 }
89 }
90
91 #[must_use]
93 pub fn output(self, path: impl AsRef<Path>) -> Self {
94 Self {
95 output: path.as_ref().to_path_buf(),
96 ..self
97 }
98 }
99
100 pub fn run(self) -> Result<(), PreviewImageError> {
110 if self.cols == 0 || self.rows == 0 {
111 return Err(PreviewImageError::OperationFailed {
112 reason: "cols/rows must be > 0".to_string(),
113 });
114 }
115 if self.frame_width == 0 || self.frame_height == 0 {
116 return Err(PreviewImageError::OperationFailed {
117 reason: "frame_width/frame_height must be > 0".to_string(),
118 });
119 }
120 if self.output.as_os_str().is_empty() {
121 return Err(PreviewImageError::OperationFailed {
122 reason: "output path not set".to_string(),
123 });
124 }
125 preview_inner::generate_sprite_sheet(
126 &self.input,
127 self.cols,
128 self.rows,
129 self.frame_width,
130 self.frame_height,
131 &self.output,
132 )
133 }
134}
135
136pub struct GifPreview {
156 input: PathBuf,
157 start: Duration,
158 duration: Duration,
159 fps: f64,
160 width: u32,
161 output: PathBuf,
162}
163
164impl GifPreview {
165 pub fn new(input: impl AsRef<Path>) -> Self {
170 Self {
171 input: input.as_ref().to_path_buf(),
172 start: Duration::ZERO,
173 duration: Duration::from_secs(3),
174 fps: 10.0,
175 width: 320,
176 output: PathBuf::new(),
177 }
178 }
179
180 #[must_use]
182 pub fn start(self, t: Duration) -> Self {
183 Self { start: t, ..self }
184 }
185
186 #[must_use]
188 pub fn duration(self, d: Duration) -> Self {
189 Self {
190 duration: d,
191 ..self
192 }
193 }
194
195 #[must_use]
197 pub fn fps(self, fps: f64) -> Self {
198 Self { fps, ..self }
199 }
200
201 #[must_use]
204 pub fn width(self, w: u32) -> Self {
205 Self { width: w, ..self }
206 }
207
208 #[must_use]
212 pub fn output(self, path: impl AsRef<Path>) -> Self {
213 Self {
214 output: path.as_ref().to_path_buf(),
215 ..self
216 }
217 }
218
219 pub fn run(self) -> Result<(), PreviewImageError> {
227 if self.output.as_os_str().is_empty() {
228 return Err(PreviewImageError::OperationFailed {
229 reason: "output path not set".to_string(),
230 });
231 }
232 if self.output.extension().and_then(|e| e.to_str()) != Some("gif") {
233 return Err(PreviewImageError::OperationFailed {
234 reason: "output path must have .gif extension".to_string(),
235 });
236 }
237 if self.fps <= 0.0 {
238 return Err(PreviewImageError::OperationFailed {
239 reason: "fps must be positive".to_string(),
240 });
241 }
242 if self.width == 0 {
243 return Err(PreviewImageError::OperationFailed {
244 reason: "width must be > 0".to_string(),
245 });
246 }
247 preview_inner::generate_gif_preview(
248 &self.input,
249 self.start,
250 self.duration,
251 self.fps,
252 self.width,
253 &self.output,
254 )
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn sprite_sheet_zero_cols_should_return_media_operation_failed() {
264 let result = SpriteSheet::new("irrelevant.mp4")
265 .cols(0)
266 .output("out.png")
267 .run();
268 assert!(
269 matches!(result, Err(PreviewImageError::OperationFailed { .. })),
270 "expected OperationFailed for cols=0, got {result:?}"
271 );
272 }
273
274 #[test]
275 fn sprite_sheet_zero_frame_width_should_return_media_operation_failed() {
276 let result = SpriteSheet::new("irrelevant.mp4")
277 .frame_width(0)
278 .output("out.png")
279 .run();
280 assert!(
281 matches!(result, Err(PreviewImageError::OperationFailed { .. })),
282 "expected OperationFailed for frame_width=0, got {result:?}"
283 );
284 }
285
286 #[test]
287 fn sprite_sheet_missing_output_should_return_media_operation_failed() {
288 let result = SpriteSheet::new("irrelevant.mp4").run();
289 assert!(
290 matches!(result, Err(PreviewImageError::OperationFailed { .. })),
291 "expected OperationFailed for empty output path, got {result:?}"
292 );
293 }
294
295 #[test]
296 fn gif_preview_non_gif_extension_should_return_media_operation_failed() {
297 let result = GifPreview::new("irrelevant.mp4").output("out.mp4").run();
298 assert!(
299 matches!(result, Err(PreviewImageError::OperationFailed { .. })),
300 "expected OperationFailed for non-.gif extension, got {result:?}"
301 );
302 }
303
304 #[test]
305 fn gif_preview_missing_output_should_return_media_operation_failed() {
306 let result = GifPreview::new("irrelevant.mp4").run();
307 assert!(
308 matches!(result, Err(PreviewImageError::OperationFailed { .. })),
309 "expected OperationFailed for missing output path, got {result:?}"
310 );
311 }
312
313 #[test]
314 fn gif_preview_zero_fps_should_return_media_operation_failed() {
315 let result = GifPreview::new("irrelevant.mp4")
316 .fps(0.0)
317 .output("out.gif")
318 .run();
319 assert!(
320 matches!(result, Err(PreviewImageError::OperationFailed { .. })),
321 "expected OperationFailed for fps=0, got {result:?}"
322 );
323 }
324}