Skip to main content

simple_gal/imaging/
operations.rs

1//! High-level image operations.
2//!
3//! These functions combine calculations with backend execution.
4//! They take configuration, compute parameters, and call the backend.
5
6use super::backend::{BackendError, ImageBackend};
7use super::calculations::{
8    ResponsiveSize, calculate_responsive_sizes, calculate_thumbnail_dimensions,
9};
10use super::params::{Quality, ResizeParams, Sharpening, ThumbnailParams};
11use std::path::Path;
12
13/// Result type for image operations.
14pub type Result<T> = std::result::Result<T, BackendError>;
15
16/// Get image dimensions using the backend.
17pub fn get_dimensions(backend: &impl ImageBackend, path: &Path) -> Result<(u32, u32)> {
18    let dims = backend.identify(path)?;
19    Ok((dims.width, dims.height))
20}
21
22/// Generated image variant with paths and dimensions.
23#[derive(Debug, Clone)]
24pub struct GeneratedVariant {
25    pub target_size: u32,
26    pub avif_path: String,
27    pub width: u32,
28    pub height: u32,
29}
30
31/// Configuration for responsive image generation.
32#[derive(Debug, Clone)]
33pub struct ResponsiveConfig {
34    pub sizes: Vec<u32>,
35    pub quality: Quality,
36}
37
38/// Create responsive images at multiple sizes.
39///
40/// Generates AVIF variants for each applicable size.
41/// Sizes larger than the original are skipped.
42pub fn create_responsive_images(
43    backend: &impl ImageBackend,
44    source: &Path,
45    output_dir: &Path,
46    filename_stem: &str,
47    original_dims: (u32, u32),
48    config: &ResponsiveConfig,
49) -> Result<Vec<GeneratedVariant>> {
50    let sizes = calculate_responsive_sizes(original_dims, &config.sizes);
51    let mut variants = Vec::new();
52
53    for ResponsiveSize {
54        target,
55        width,
56        height,
57    } in sizes
58    {
59        let avif_name = format!("{}-{}.avif", filename_stem, target);
60        let avif_path = output_dir.join(&avif_name);
61
62        backend.resize(&ResizeParams {
63            source: source.to_path_buf(),
64            output: avif_path.clone(),
65            width,
66            height,
67            quality: config.quality,
68        })?;
69
70        // Compute relative path for manifest
71        let relative_dir = output_dir
72            .file_name()
73            .map(|s| s.to_str().unwrap())
74            .unwrap_or("");
75
76        variants.push(GeneratedVariant {
77            target_size: target,
78            avif_path: format!("{}/{}", relative_dir, avif_name),
79            width,
80            height,
81        });
82    }
83
84    Ok(variants)
85}
86
87/// Configuration for thumbnail generation.
88#[derive(Debug, Clone)]
89pub struct ThumbnailConfig {
90    pub aspect: (u32, u32),
91    pub short_edge: u32,
92    pub quality: Quality,
93    pub sharpening: Option<Sharpening>,
94}
95
96impl Default for ThumbnailConfig {
97    fn default() -> Self {
98        Self {
99            aspect: (4, 5),
100            short_edge: 400,
101            quality: Quality::default(),
102            sharpening: Some(Sharpening::light()),
103        }
104    }
105}
106
107/// Plan a thumbnail operation without executing it.
108///
109/// Useful for testing parameter generation.
110pub fn plan_thumbnail(
111    source: &Path,
112    output_path: &Path,
113    config: &ThumbnailConfig,
114) -> ThumbnailParams {
115    let (crop_w, crop_h) = calculate_thumbnail_dimensions(config.aspect, config.short_edge);
116
117    ThumbnailParams {
118        source: source.to_path_buf(),
119        output: output_path.to_path_buf(),
120        crop_width: crop_w,
121        crop_height: crop_h,
122        quality: config.quality,
123        sharpening: config.sharpening,
124    }
125}
126
127/// Create a thumbnail image.
128///
129/// Resizes to fill the target aspect ratio, then center-crops.
130pub fn create_thumbnail(
131    backend: &impl ImageBackend,
132    source: &Path,
133    output_dir: &Path,
134    filename_stem: &str,
135    config: &ThumbnailConfig,
136) -> Result<String> {
137    let thumb_name = format!("{}-thumb.avif", filename_stem);
138    let thumb_path = output_dir.join(&thumb_name);
139
140    let params = plan_thumbnail(source, &thumb_path, config);
141    backend.thumbnail(&params)?;
142
143    let relative_dir = output_dir
144        .file_name()
145        .map(|s| s.to_str().unwrap())
146        .unwrap_or("");
147
148    Ok(format!("{}/{}", relative_dir, thumb_name))
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::imaging::Dimensions;
155    use crate::imaging::backend::tests::{MockBackend, RecordedOp};
156
157    #[test]
158    fn get_dimensions_calls_backend() {
159        let backend = MockBackend::with_dimensions(vec![Dimensions {
160            width: 1920,
161            height: 1080,
162        }]);
163
164        let dims = get_dimensions(&backend, Path::new("/test.jpg")).unwrap();
165        assert_eq!(dims, (1920, 1080));
166    }
167
168    #[test]
169    fn plan_thumbnail_calculates_crop_dimensions() {
170        // 4:5 portrait thumb at 400 short edge → crop 400x500
171        let params = plan_thumbnail(
172            Path::new("/source.jpg"),
173            Path::new("/thumb.avif"),
174            &ThumbnailConfig::default(),
175        );
176
177        assert_eq!(params.crop_width, 400);
178        assert_eq!(params.crop_height, 500);
179    }
180
181    #[test]
182    fn plan_thumbnail_landscape_aspect() {
183        let config = ThumbnailConfig {
184            aspect: (16, 9),
185            short_edge: 180,
186            ..ThumbnailConfig::default()
187        };
188        let params = plan_thumbnail(Path::new("/source.jpg"), Path::new("/thumb.avif"), &config);
189
190        assert_eq!(params.crop_width, 320);
191        assert_eq!(params.crop_height, 180);
192    }
193
194    #[test]
195    fn create_thumbnail_uses_backend() {
196        let backend = MockBackend::new();
197        let config = ThumbnailConfig::default();
198
199        let result = create_thumbnail(
200            &backend,
201            Path::new("/source.jpg"),
202            Path::new("/output"),
203            "001-test",
204            &config,
205        )
206        .unwrap();
207
208        assert_eq!(result, "output/001-test-thumb.avif");
209
210        let ops = backend.get_operations();
211        assert_eq!(ops.len(), 1);
212        assert!(matches!(
213            &ops[0],
214            RecordedOp::Thumbnail {
215                crop_width: 400,
216                crop_height: 500,
217                ..
218            }
219        ));
220    }
221
222    #[test]
223    fn create_responsive_caps_at_source_size() {
224        let backend = MockBackend::new();
225        let config = ResponsiveConfig {
226            sizes: vec![800, 1400, 2080],
227            quality: Quality::default(),
228        };
229
230        // Original is 1000px - 800 fits, 1400 and 2080 cap to 1000 (deduped)
231        let variants = create_responsive_images(
232            &backend,
233            Path::new("/source.jpg"),
234            Path::new("/output"),
235            "001-test",
236            (1000, 750),
237            &config,
238        )
239        .unwrap();
240
241        assert_eq!(variants.len(), 2);
242        assert_eq!(variants[0].target_size, 800);
243        assert_eq!(variants[1].target_size, 1000); // capped from 1400
244
245        let ops = backend.get_operations();
246        assert_eq!(ops.len(), 2);
247    }
248
249    #[test]
250    fn create_responsive_generates_avif() {
251        let backend = MockBackend::new();
252        let config = ResponsiveConfig {
253            sizes: vec![800],
254            quality: Quality::new(85),
255        };
256
257        create_responsive_images(
258            &backend,
259            Path::new("/source.jpg"),
260            Path::new("/output"),
261            "001-test",
262            (2000, 1500),
263            &config,
264        )
265        .unwrap();
266
267        let ops = backend.get_operations();
268        assert_eq!(ops.len(), 1);
269
270        assert!(matches!(
271            &ops[0],
272            RecordedOp::Resize { output, quality: 85, .. } if output.ends_with(".avif")
273        ));
274    }
275
276    #[test]
277    fn create_responsive_fallback_to_original_size() {
278        let backend = MockBackend::new();
279        let config = ResponsiveConfig {
280            sizes: vec![800, 1400],
281            quality: Quality::default(),
282        };
283
284        // Original is only 500px - smaller than all targets
285        let variants = create_responsive_images(
286            &backend,
287            Path::new("/source.jpg"),
288            Path::new("/output"),
289            "001-test",
290            (500, 400),
291            &config,
292        )
293        .unwrap();
294
295        assert_eq!(variants.len(), 1);
296        assert_eq!(variants[0].target_size, 500); // Uses original size
297        assert_eq!(variants[0].width, 500);
298        assert_eq!(variants[0].height, 400);
299    }
300}