1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
//! Image transformation engine for resizing and format conversion.
use std::io::Cursor;
use fraiseql_error::{FraiseQLError, Result};
use image::ImageReader;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// Output format for transformed images
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OutputFormat {
/// `WebP` format (modern, efficient)
Webp,
/// JPEG format (lossy, widely supported)
Jpeg,
/// PNG format (lossless)
Png,
/// AVIF format (modern, efficient)
Avif,
/// BMP format (intentionally unsupported)
Bmp,
}
impl OutputFormat {
/// Get the MIME type for this format
#[must_use]
pub const fn mime_type(self) -> &'static str {
match self {
Self::Webp => "image/webp",
Self::Jpeg => "image/jpeg",
Self::Png => "image/png",
Self::Avif => "image/avif",
Self::Bmp => "image/bmp",
}
}
/// Get the image format for encoding
const fn as_image_format(self) -> Option<image::ImageFormat> {
match self {
Self::Webp => Some(image::ImageFormat::WebP),
Self::Jpeg => Some(image::ImageFormat::Jpeg),
Self::Png => Some(image::ImageFormat::Png),
Self::Avif => Some(image::ImageFormat::Avif),
Self::Bmp => None, // Unsupported
}
}
}
/// Parameters for image transformation
#[derive(Debug, Clone)]
pub struct TransformParams {
/// Target width in pixels (optional)
pub width: Option<u32>,
/// Target height in pixels (optional)
pub height: Option<u32>,
/// Output format (optional, defaults to input format)
pub format: Option<OutputFormat>,
/// Quality for lossy formats (1-100, default 80)
pub quality: Option<u8>,
}
/// Output from image transformation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransformOutput {
/// Transformed image bytes
pub body: Vec<u8>,
/// MIME type of output
pub content_type: String,
/// Actual output width in pixels
pub width: u32,
/// Actual output height in pixels
pub height: u32,
/// `ETag` for cache validation (SHA256 hash of transformed bytes)
#[serde(default)]
pub etag: Option<String>,
/// Cache control header value for HTTP response
#[serde(default)]
pub cache_control: Option<String>,
}
/// Image transformation engine
pub struct ImageTransformer;
impl ImageTransformer {
/// Transform an image according to the provided parameters
///
/// # Arguments
/// - `input`: Raw image bytes
/// - `params`: Transform parameters (resize, format, quality)
///
/// # Returns
/// - `Ok(TransformOutput)` on success
/// - `Err(FraiseQLError)` if the input is not a valid image, format is unsupported, etc.
///
/// # Errors
/// - `FraiseQLError::Validation` if dimensions are invalid or format is unsupported
/// - `FraiseQLError::Validation` if input is not a valid image
pub fn transform(input: &[u8], params: &TransformParams) -> Result<TransformOutput> {
// Validate dimensions
if let Some(w) = params.width {
if w == 0 {
return Err(FraiseQLError::Validation {
message: "Width must be greater than 0".to_string(),
path: Some("width".to_string()),
});
}
}
if let Some(h) = params.height {
if h == 0 {
return Err(FraiseQLError::Validation {
message: "Height must be greater than 0".to_string(),
path: Some("height".to_string()),
});
}
}
// Check if output format is supported
if let Some(fmt) = params.format {
if fmt == OutputFormat::Bmp {
return Err(FraiseQLError::Validation {
message: "BMP format is not supported for transforms".to_string(),
path: Some("format".to_string()),
});
}
if fmt.as_image_format().is_none() {
return Err(FraiseQLError::Validation {
message: format!("Format {:?} is not supported", fmt),
path: Some("format".to_string()),
});
}
}
// Decode the input image
let cursor = Cursor::new(input);
let mut reader = ImageReader::new(cursor);
// Try to infer format if not explicitly set
if reader.format().is_none() {
reader = reader.with_guessed_format().map_err(|_| FraiseQLError::Validation {
message: "Could not determine image format".to_string(),
path: Some("input".to_string()),
})?;
}
let format = reader.format();
let img = reader.decode().map_err(|_| FraiseQLError::Validation {
message: "Failed to decode image".to_string(),
path: Some("input".to_string()),
})?;
// Calculate output dimensions
let (output_width, output_height) =
Self::calculate_dimensions(img.width(), img.height(), params.width, params.height)?;
// Resize if needed
let resized = if params.width.is_some() || params.height.is_some() {
img.resize_exact(output_width, output_height, image::imageops::FilterType::Lanczos3)
} else {
img
};
// Determine output format
let output_format = if let Some(fmt) = params.format {
fmt
} else {
// Infer from input
Self::infer_format_from_image_format(format).unwrap_or(OutputFormat::Jpeg)
};
// Encode to output format
let mut output_bytes = Vec::new();
match output_format {
OutputFormat::Webp => {
resized
.write_to(&mut Cursor::new(&mut output_bytes), image::ImageFormat::WebP)
.map_err(|_| FraiseQLError::Validation {
message: "Failed to encode WebP".to_string(),
path: Some("format".to_string()),
})?;
},
OutputFormat::Jpeg => {
resized
.write_to(&mut Cursor::new(&mut output_bytes), image::ImageFormat::Jpeg)
.map_err(|_| FraiseQLError::Validation {
message: "Failed to encode JPEG".to_string(),
path: Some("format".to_string()),
})?;
},
OutputFormat::Png => {
resized
.write_to(&mut Cursor::new(&mut output_bytes), image::ImageFormat::Png)
.map_err(|_| FraiseQLError::Validation {
message: "Failed to encode PNG".to_string(),
path: Some("format".to_string()),
})?;
},
OutputFormat::Avif => {
resized
.write_to(&mut Cursor::new(&mut output_bytes), image::ImageFormat::Avif)
.map_err(|_| FraiseQLError::Validation {
message: "Failed to encode AVIF".to_string(),
path: Some("format".to_string()),
})?;
},
OutputFormat::Bmp => {
// Defense in depth: BMP is rejected by the validation block
// above. If we somehow reach here, return an error rather than
// panic so production cannot be crashed by a missed validation
// path.
return Err(FraiseQLError::Validation {
message: "BMP format is not supported for transforms".to_string(),
path: Some("format".to_string()),
});
},
}
// Compute ETag from output bytes (SHA256 hash)
let etag = {
let mut hasher = Sha256::new();
hasher.update(&output_bytes);
format!("\"{}\"", hex::encode(hasher.finalize()))
};
Ok(TransformOutput {
body: output_bytes,
content_type: output_format.mime_type().to_string(),
width: output_width,
height: output_height,
etag: Some(etag),
// Cache transformed images for 30 days (they're deterministic based on source +
// params)
cache_control: Some("public, max-age=2592000, immutable".to_string()),
})
}
/// Calculate output dimensions preserving aspect ratio.
// Reason: image dimensions are u32 but always ≤ ~32k in practice (max texture size),
// so u32→f32 precision loss and f32→u32 truncation/sign loss are bounded; zero-result
// is checked and rejected at the end of the function.
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn calculate_dimensions(
orig_width: u32,
orig_height: u32,
target_width: Option<u32>,
target_height: Option<u32>,
) -> Result<(u32, u32)> {
let (width, height) = match (target_width, target_height) {
(Some(w), Some(h)) => {
// Both specified: fit within bounds preserving aspect ratio
let aspect = orig_width as f32 / orig_height as f32;
let target_aspect = w as f32 / h as f32;
if aspect > target_aspect {
// Original is wider, scale by width
(w, (w as f32 / aspect) as u32)
} else {
// Original is taller, scale by height
((h as f32 * aspect) as u32, h)
}
},
(Some(w), None) => {
// Only width specified, calculate height
let h = (w as f32 * orig_height as f32 / orig_width as f32) as u32;
(w, h)
},
(None, Some(h)) => {
// Only height specified, calculate width
let w = (h as f32 * orig_width as f32 / orig_height as f32) as u32;
(w, h)
},
(None, None) => {
// No dimensions specified, use original
(orig_width, orig_height)
},
};
if width == 0 || height == 0 {
return Err(FraiseQLError::Validation {
message: "Calculated dimensions would be zero".to_string(),
path: Some("dimensions".to_string()),
});
}
Ok((width, height))
}
/// Infer output format from the decoded image format
const fn infer_format_from_image_format(
format: Option<image::ImageFormat>,
) -> Option<OutputFormat> {
match format {
Some(image::ImageFormat::WebP) => Some(OutputFormat::Webp),
Some(image::ImageFormat::Jpeg) => Some(OutputFormat::Jpeg),
Some(image::ImageFormat::Png) => Some(OutputFormat::Png),
Some(image::ImageFormat::Avif) => Some(OutputFormat::Avif),
_ => None,
}
}
}
impl std::fmt::Display for OutputFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.mime_type())
}
}
impl ImageTransformer {
/// Apply a transform preset to get a `TransformParams`
///
/// Presets are named sets of transform parameters that can be defined in bucket configuration.
/// This helper converts a preset into `TransformParams` for use with the transform method.
///
/// # Arguments
/// - `preset_name` - Name of the preset to look up
/// - `presets` - Available presets (typically from `BucketConfig.transform_presets`)
///
/// # Returns
/// - `Some(TransformParams)` if preset is found
/// - `None` if preset is not found
#[must_use]
pub fn apply_preset(
preset_name: &str,
presets: Option<&[crate::config::TransformPreset]>,
) -> Option<TransformParams> {
let presets = presets?;
let preset = presets.iter().find(|p| p.name == preset_name)?;
let format = preset.format.as_ref().and_then(|f| match f.to_lowercase().as_str() {
"webp" => Some(OutputFormat::Webp),
"jpeg" | "jpg" => Some(OutputFormat::Jpeg),
"png" => Some(OutputFormat::Png),
"avif" => Some(OutputFormat::Avif),
_ => None,
});
Some(TransformParams {
width: preset.width,
height: preset.height,
format,
quality: preset.quality,
})
}
}