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