1use std::io::Cursor;
2use std::num::NonZeroUsize;
3use std::path::Path;
4use std::sync::Arc;
5use std::sync::LazyLock;
6
7use base64::Engine;
8use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
9use codex_utils_cache::BlockingLruCache;
10use codex_utils_cache::sha1_digest;
11use image::ColorType;
12use image::DynamicImage;
13use image::GenericImageView;
14use image::ImageDecoder;
15use image::ImageEncoder;
16use image::ImageFormat;
17use image::ImageReader;
18use image::codecs::jpeg::JpegEncoder;
19use image::codecs::png::PngEncoder;
20use image::codecs::webp::WebPEncoder;
21use image::imageops::FilterType;
22
23const DATA_URL_PREFIX: &str = "data:";
24pub const PROMPT_IMAGE_PATCH_SIZE: u32 = 32;
25pub const MAX_DIMENSION: u32 = 2048;
27pub const MAX_PROMPT_IMAGE_INPUT_BYTES: usize = 1024 * 1024 * 1024;
32const MAX_IMAGE_CACHE_BYTES: usize = 64 * 1024 * 1024;
33
34pub mod error;
35
36pub use crate::error::ImageProcessingError;
37
38#[derive(Debug, Clone)]
39pub struct EncodedImage {
40 pub bytes: Arc<[u8]>,
41 pub mime: String,
42 pub width: u32,
43 pub height: u32,
44}
45
46impl EncodedImage {
47 pub fn into_data_url(self) -> String {
48 data_url_from_bytes(&self.mime, &self.bytes)
49 }
50}
51
52pub fn data_url_from_bytes(mime: &str, bytes: &[u8]) -> String {
54 let encoded = BASE64_STANDARD.encode(bytes);
55 format!("data:{mime};base64,{encoded}")
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub enum PromptImageMode {
60 ResizeToFit,
61 Original,
62 ResizeWithLimits(PromptImageResizeLimits),
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66pub struct PromptImageResizeLimits {
67 pub max_dimension: u32,
68 pub max_patches: usize,
69}
70
71struct ImageMetadata {
72 icc_profile: Option<Vec<u8>>,
73 exif: Option<Vec<u8>>,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
77struct ImageCacheKey {
78 digest: [u8; 20],
79 mode: PromptImageMode,
80}
81
82type ImageCache = BlockingLruCache<ImageCacheKey, EncodedImage>;
83
84static IMAGE_CACHE: LazyLock<ImageCache> =
85 LazyLock::new(|| BlockingLruCache::new(NonZeroUsize::new(32).unwrap_or(NonZeroUsize::MIN)));
86
87pub fn load_for_prompt_bytes(
88 path: &Path,
89 file_bytes: Vec<u8>,
90 mode: PromptImageMode,
91) -> Result<EncodedImage, ImageProcessingError> {
92 let path_buf = path.to_path_buf();
93
94 let key = ImageCacheKey {
95 digest: sha1_digest(&file_bytes),
96 mode,
97 };
98
99 if let Some(image) = IMAGE_CACHE.get(&key) {
100 return Ok(image);
101 }
102
103 let image = (move || {
104 let guessed_format = image::guess_format(&file_bytes)
105 .map_err(|source| ImageProcessingError::decode_error(&path_buf, source))?;
106 let format = match guessed_format {
107 ImageFormat::Png => Some(ImageFormat::Png),
108 ImageFormat::Jpeg => Some(ImageFormat::Jpeg),
109 ImageFormat::Gif => Some(ImageFormat::Gif),
110 ImageFormat::WebP => Some(ImageFormat::WebP),
111 _ => None,
112 };
113
114 let mut decoder = ImageReader::with_format(Cursor::new(&file_bytes), guessed_format)
115 .into_decoder()
116 .map_err(|source| ImageProcessingError::decode_error(&path_buf, source))?;
117 let metadata = ImageMetadata {
121 icc_profile: decoder
125 .icc_profile()
126 .ok()
127 .flatten()
128 .filter(|profile| profile.get(16..20) == Some(b"RGB ")),
129 exif: decoder.exif_metadata().ok().flatten(),
130 };
131 let dynamic = DynamicImage::from_decoder(decoder)
132 .map_err(|source| ImageProcessingError::decode_error(&path_buf, source))?;
133
134 let (width, height) = dynamic.dimensions();
135
136 let target_dimensions = match mode {
137 PromptImageMode::ResizeToFit if width > MAX_DIMENSION || height > MAX_DIMENSION => {
138 let resized = dynamic.resize(MAX_DIMENSION, MAX_DIMENSION, FilterType::Triangle);
139 Some((resized.width(), resized.height(), resized))
140 }
141 PromptImageMode::ResizeWithLimits(limits) => {
142 let (target_width, target_height) =
143 prompt_image_output_dimensions_for_limits(width, height, limits);
144 if (target_width, target_height) == (width, height) {
145 None
146 } else {
147 let resized =
148 dynamic.resize_exact(target_width, target_height, FilterType::Triangle);
149 Some((target_width, target_height, resized))
150 }
151 }
152 PromptImageMode::ResizeToFit | PromptImageMode::Original => None,
153 };
154
155 let encoded = if let Some((width, height, resized)) = target_dimensions {
156 let target_format = format
157 .filter(|format| can_preserve_source_bytes(*format))
158 .unwrap_or(ImageFormat::Png);
159 let (bytes, output_format) = encode_image(&resized, target_format, metadata)?;
160 let mime = format_to_mime(output_format);
161 EncodedImage {
162 bytes: bytes.into(),
163 mime,
164 width,
165 height,
166 }
167 } else {
168 if let Some(format) = format.filter(|format| can_preserve_source_bytes(*format)) {
169 let mime = format_to_mime(format);
170 EncodedImage {
171 bytes: file_bytes.into(),
172 mime,
173 width,
174 height,
175 }
176 } else {
177 let (bytes, output_format) = encode_image(&dynamic, ImageFormat::Png, metadata)?;
178 let mime = format_to_mime(output_format);
179 EncodedImage {
180 bytes: bytes.into(),
181 mime,
182 width,
183 height,
184 }
185 }
186 };
187
188 Ok(encoded)
189 })()?;
190
191 cache_image(&IMAGE_CACHE, key, image.clone(), MAX_IMAGE_CACHE_BYTES);
192 Ok(image)
193}
194
195fn cache_image(cache: &ImageCache, key: ImageCacheKey, image: EncodedImage, byte_capacity: usize) {
196 if image.bytes.len() > byte_capacity {
197 return;
198 }
199
200 cache.with_mut(|cache| {
201 cache.put(key, image);
202 let mut cached_bytes = cache
203 .iter()
204 .map(|(_, image)| image.bytes.len())
205 .sum::<usize>();
206 while cached_bytes > byte_capacity {
207 let Some((_, evicted)) = cache.pop_lru() else {
208 break;
209 };
210 cached_bytes -= evicted.bytes.len();
211 }
212 });
213}
214
215pub fn load_data_url_for_prompt(
216 image_url: &str,
217 mode: PromptImageMode,
218) -> Result<EncodedImage, ImageProcessingError> {
219 let rest = image_url
220 .get(..DATA_URL_PREFIX.len())
221 .filter(|prefix| prefix.eq_ignore_ascii_case(DATA_URL_PREFIX))
222 .and_then(|_| image_url.get(DATA_URL_PREFIX.len()..))
223 .ok_or_else(|| ImageProcessingError::InvalidDataUrl {
224 reason: "missing data: prefix".to_string(),
225 })?;
226 let (metadata, encoded) =
227 rest.split_once(',')
228 .ok_or_else(|| ImageProcessingError::InvalidDataUrl {
229 reason: "missing comma separator".to_string(),
230 })?;
231 if !metadata
232 .split(';')
233 .any(|part| part.eq_ignore_ascii_case("base64"))
234 {
235 return Err(ImageProcessingError::InvalidDataUrl {
236 reason: "only base64 data URLs are supported".to_string(),
237 });
238 }
239
240 if encoded.len() > MAX_PROMPT_IMAGE_INPUT_BYTES {
241 return Err(ImageProcessingError::ImageTooLarge {
242 representation: "base64 payload",
243 size: encoded.len(),
244 max: MAX_PROMPT_IMAGE_INPUT_BYTES,
245 });
246 }
247 let file_bytes =
248 BASE64_STANDARD
249 .decode(encoded)
250 .map_err(|source| ImageProcessingError::InvalidDataUrl {
251 reason: format!("invalid base64 payload: {source}"),
252 })?;
253 if file_bytes.len() > MAX_PROMPT_IMAGE_INPUT_BYTES {
254 return Err(ImageProcessingError::ImageTooLarge {
255 representation: "decoded input",
256 size: file_bytes.len(),
257 max: MAX_PROMPT_IMAGE_INPUT_BYTES,
258 });
259 }
260
261 load_for_prompt_bytes(Path::new("<data-url-image>"), file_bytes, mode)
262}
263
264fn prompt_image_output_dimensions_for_limits(
265 width: u32,
266 height: u32,
267 limits: PromptImageResizeLimits,
268) -> (u32, u32) {
269 let width = width.max(1);
270 let height = height.max(1);
271 if prompt_image_dimensions_fit(width, height, limits) {
272 return (width, height);
273 }
274
275 let max_dimension_scale =
276 (f64::from(limits.max_dimension) / f64::from(width.max(height))).min(1.0);
277 let width = ((f64::from(width) * max_dimension_scale).round() as u32).max(1);
278 let height = ((f64::from(height) * max_dimension_scale).round() as u32).max(1);
279 if prompt_image_dimensions_fit(width, height, limits) {
280 return (width, height);
281 }
282
283 let width_f64 = f64::from(width);
284 let height_f64 = f64::from(height);
285 let patch_size = f64::from(PROMPT_IMAGE_PATCH_SIZE);
286 let mut scale =
287 (patch_size * patch_size * limits.max_patches as f64 / width_f64 / height_f64).sqrt();
288 let scaled_patches_wide = width_f64 * scale / patch_size;
291 let scaled_patches_high = height_f64 * scale / patch_size;
292 scale *= (scaled_patches_wide.floor() / scaled_patches_wide)
293 .min(scaled_patches_high.floor() / scaled_patches_high);
294
295 (
296 ((width_f64 * scale).floor() as u32).max(1),
297 ((height_f64 * scale).floor() as u32).max(1),
298 )
299}
300
301fn prompt_image_dimensions_fit(width: u32, height: u32, limits: PromptImageResizeLimits) -> bool {
302 let patches_wide = width.div_ceil(PROMPT_IMAGE_PATCH_SIZE);
303 let patches_high = height.div_ceil(PROMPT_IMAGE_PATCH_SIZE);
304 let patch_count = u64::from(patches_wide) * u64::from(patches_high);
305 width <= limits.max_dimension
306 && height <= limits.max_dimension
307 && patch_count <= limits.max_patches as u64
308}
309
310fn can_preserve_source_bytes(format: ImageFormat) -> bool {
311 matches!(
314 format,
315 ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::WebP
316 )
317}
318
319fn encode_image(
320 image: &DynamicImage,
321 preferred_format: ImageFormat,
322 metadata: ImageMetadata,
323) -> Result<(Vec<u8>, ImageFormat), ImageProcessingError> {
324 let target_format = match preferred_format {
325 ImageFormat::Jpeg => ImageFormat::Jpeg,
326 ImageFormat::WebP => ImageFormat::WebP,
327 _ => ImageFormat::Png,
328 };
329
330 let mut buffer = Vec::new();
331 let ImageMetadata { icc_profile, exif } = metadata;
332
333 match target_format {
334 ImageFormat::Png => {
335 let rgba = image.to_rgba8();
336 let mut encoder = PngEncoder::new(&mut buffer);
337 apply_image_metadata(&mut encoder, icc_profile, exif, target_format)?;
338 encoder
339 .write_image(
340 rgba.as_raw(),
341 image.width(),
342 image.height(),
343 ColorType::Rgba8.into(),
344 )
345 .map_err(|source| ImageProcessingError::Encode {
346 format: target_format,
347 source,
348 })?;
349 }
350 ImageFormat::Jpeg => {
351 let mut encoder = JpegEncoder::new_with_quality(&mut buffer, 85);
352 apply_image_metadata(&mut encoder, icc_profile, exif, target_format)?;
353 encoder
354 .encode_image(image)
355 .map_err(|source| ImageProcessingError::Encode {
356 format: target_format,
357 source,
358 })?;
359 }
360 ImageFormat::WebP => {
361 let rgba = image.to_rgba8();
362 let mut encoder = WebPEncoder::new_lossless(&mut buffer);
363 apply_image_metadata(&mut encoder, icc_profile, exif, target_format)?;
364 encoder
365 .write_image(
366 rgba.as_raw(),
367 image.width(),
368 image.height(),
369 ColorType::Rgba8.into(),
370 )
371 .map_err(|source| ImageProcessingError::Encode {
372 format: target_format,
373 source,
374 })?;
375 }
376 _ => unreachable!("unsupported target_format should have been handled earlier"),
377 }
378
379 Ok((buffer, target_format))
380}
381
382fn apply_image_metadata(
383 encoder: &mut impl ImageEncoder,
384 icc_profile: Option<Vec<u8>>,
385 exif: Option<Vec<u8>>,
386 format: ImageFormat,
387) -> Result<(), ImageProcessingError> {
388 if let Some(icc_profile) = icc_profile {
389 encoder
390 .set_icc_profile(icc_profile)
391 .map_err(|source| ImageProcessingError::Encode {
392 format,
393 source: image::ImageError::Unsupported(source),
394 })?;
395 }
396 if let Some(exif) = exif {
397 encoder
398 .set_exif_metadata(exif)
399 .map_err(|source| ImageProcessingError::Encode {
400 format,
401 source: image::ImageError::Unsupported(source),
402 })?;
403 }
404 Ok(())
405}
406
407fn format_to_mime(format: ImageFormat) -> String {
408 match format {
409 ImageFormat::Jpeg => "image/jpeg".to_string(),
410 ImageFormat::Gif => "image/gif".to_string(),
411 ImageFormat::WebP => "image/webp".to_string(),
412 _ => "image/png".to_string(),
413 }
414}
415
416#[cfg(test)]
417#[path = "image_tests.rs"]
418mod tests;