Skip to main content

codex_utils_image/
lib.rs

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;
25/// Maximum width or height used when resizing images before uploading.
26pub const MAX_DIMENSION: u32 = 2048;
27/// Maximum accepted byte length for prompt image input representations.
28///
29/// This is a high sanity guard against pathological inputs, not a protocol
30/// requirement or target upload size.
31pub 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
52/// Wraps image bytes in a data URL without decoding or validating them.
53pub 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        // Preserve the metadata most important for rendering prompt images faithfully: the color
118        // profile and EXIF data, including orientation. Other format-specific metadata is
119        // intentionally not copied.
120        let metadata = ImageMetadata {
121            // Only RGB profiles are safe across every re-encoding path. For example, JPEG decoding
122            // can convert CMYK/YCCK pixels to RGB while retaining the source profile; copying it
123            // would mislabel the output. Bytes 16..20 are the ICC data color space signature.
124            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    // Match Responses patch-budget math: shrink by area, then round the scaled
289    // patch grid down so integer output dimensions remain within the budget.
290    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    // Public API docs explicitly call out non-animated GIF support only.
312    // Preserve byte-for-byte only for formats we can safely pass through.
313    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;