mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
aae4c0b02b
## Why The prompt image cache was bounded to 32 entries, but not by the size of those entries. A set of large encoded images could therefore retain substantially more memory than intended. Cache hits also cloned the full encoded payload. ## What changed - cap the cache at 64 MiB of encoded image data while preserving its existing 32-entry limit - skip caching an image that exceeds the entire byte budget - evict least-recently-used entries until the cache is back within its byte budget - share cached encoded bytes with `Arc<[u8]>` so cache hits do not deep-clone image payloads ## Validation - `just test -p codex-utils-image`
365 lines
12 KiB
Rust
365 lines
12 KiB
Rust
use std::io::Cursor;
|
|
|
|
use super::*;
|
|
use image::GenericImageView;
|
|
use image::ImageBuffer;
|
|
use image::ImageDecoder;
|
|
use image::Rgba;
|
|
use image::metadata::Orientation;
|
|
|
|
const TEST_RGB_ICC_PROFILE: &[u8] = b"0123456789abcdefRGB ";
|
|
const TEST_CMYK_ICC_PROFILE: &[u8] = b"0123456789abcdefCMYK";
|
|
const ROTATE_90_EXIF: &[u8] = &[
|
|
0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x12, 0x01, 0x03, 0x00, 0x01, 0x00,
|
|
0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
];
|
|
|
|
fn image_bytes(image: &ImageBuffer<Rgba<u8>, Vec<u8>>, format: ImageFormat) -> Vec<u8> {
|
|
let mut encoded = Cursor::new(Vec::new());
|
|
DynamicImage::ImageRgba8(image.clone())
|
|
.write_to(&mut encoded, format)
|
|
.expect("encode image to bytes");
|
|
encoded.into_inner()
|
|
}
|
|
|
|
fn image_bytes_with_metadata(
|
|
image: &ImageBuffer<Rgba<u8>, Vec<u8>>,
|
|
format: ImageFormat,
|
|
icc_profile: &[u8],
|
|
) -> Vec<u8> {
|
|
let mut encoded = Vec::new();
|
|
match format {
|
|
ImageFormat::Png => {
|
|
let mut encoder = PngEncoder::new(&mut encoded);
|
|
encoder
|
|
.set_icc_profile(icc_profile.to_vec())
|
|
.expect("set PNG ICC profile");
|
|
encoder
|
|
.set_exif_metadata(ROTATE_90_EXIF.to_vec())
|
|
.expect("set PNG EXIF metadata");
|
|
encoder
|
|
.write_image(
|
|
image.as_raw(),
|
|
image.width(),
|
|
image.height(),
|
|
ColorType::Rgba8.into(),
|
|
)
|
|
.expect("encode PNG with metadata");
|
|
}
|
|
ImageFormat::Jpeg => {
|
|
let mut encoder = JpegEncoder::new_with_quality(&mut encoded, 90);
|
|
encoder
|
|
.set_icc_profile(icc_profile.to_vec())
|
|
.expect("set JPEG ICC profile");
|
|
encoder
|
|
.set_exif_metadata(ROTATE_90_EXIF.to_vec())
|
|
.expect("set JPEG EXIF metadata");
|
|
encoder
|
|
.encode_image(&DynamicImage::ImageRgba8(image.clone()))
|
|
.expect("encode JPEG with metadata");
|
|
}
|
|
ImageFormat::WebP => {
|
|
let mut encoder = WebPEncoder::new_lossless(&mut encoded);
|
|
encoder
|
|
.set_icc_profile(icc_profile.to_vec())
|
|
.expect("set WebP ICC profile");
|
|
encoder
|
|
.set_exif_metadata(ROTATE_90_EXIF.to_vec())
|
|
.expect("set WebP EXIF metadata");
|
|
encoder
|
|
.write_image(
|
|
image.as_raw(),
|
|
image.width(),
|
|
image.height(),
|
|
ColorType::Rgba8.into(),
|
|
)
|
|
.expect("encode WebP with metadata");
|
|
}
|
|
_ => panic!("unsupported test format"),
|
|
}
|
|
encoded
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn returns_original_image_when_within_bounds() {
|
|
for (format, mime) in [
|
|
(ImageFormat::Png, "image/png"),
|
|
(ImageFormat::WebP, "image/webp"),
|
|
] {
|
|
let image = ImageBuffer::from_pixel(64, 32, Rgba([10u8, 20, 30, 255]));
|
|
let original_bytes = image_bytes(&image, format);
|
|
|
|
let encoded = load_for_prompt_bytes(
|
|
Path::new("in-memory-image"),
|
|
original_bytes.clone(),
|
|
PromptImageMode::ResizeToFit,
|
|
)
|
|
.expect("process image");
|
|
|
|
assert_eq!(encoded.width, 64);
|
|
assert_eq!(encoded.height, 32);
|
|
assert_eq!(encoded.mime, mime);
|
|
assert_eq!(encoded.bytes.as_ref(), original_bytes);
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn downscales_large_image() {
|
|
for (format, mime) in [
|
|
(ImageFormat::Png, "image/png"),
|
|
(ImageFormat::WebP, "image/webp"),
|
|
] {
|
|
let image = ImageBuffer::from_pixel(4096, 2048, Rgba([200u8, 10, 10, 255]));
|
|
let original_bytes = image_bytes(&image, format);
|
|
|
|
let processed = load_for_prompt_bytes(
|
|
Path::new("in-memory-image"),
|
|
original_bytes,
|
|
PromptImageMode::ResizeToFit,
|
|
)
|
|
.expect("process image");
|
|
|
|
assert!(processed.width <= MAX_DIMENSION);
|
|
assert!(processed.height <= MAX_DIMENSION);
|
|
assert_eq!(processed.mime, mime);
|
|
|
|
let detected_format =
|
|
image::guess_format(&processed.bytes).expect("detect resized output format");
|
|
assert_eq!(detected_format, format);
|
|
|
|
let loaded =
|
|
image::load_from_memory(&processed.bytes).expect("read resized bytes back into image");
|
|
assert_eq!(loaded.dimensions(), (processed.width, processed.height));
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn downscales_tall_image_to_fit_square_bounds() {
|
|
let image = ImageBuffer::from_pixel(1024, 4096, Rgba([200u8, 10, 10, 255]));
|
|
let original_bytes = image_bytes(&image, ImageFormat::Png);
|
|
|
|
let processed = load_for_prompt_bytes(
|
|
Path::new("in-memory-image"),
|
|
original_bytes,
|
|
PromptImageMode::ResizeToFit,
|
|
)
|
|
.expect("process image");
|
|
|
|
assert_eq!(processed.width, 512);
|
|
assert_eq!(processed.height, MAX_DIMENSION);
|
|
assert_eq!(processed.mime, "image/png");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn resizing_preserves_supported_metadata() {
|
|
for format in [ImageFormat::Png, ImageFormat::Jpeg, ImageFormat::WebP] {
|
|
let image = ImageBuffer::from_pixel(2050, 2, Rgba([200u8, 10, 10, 255]));
|
|
let original_bytes = image_bytes_with_metadata(&image, format, TEST_RGB_ICC_PROFILE);
|
|
|
|
let processed = load_for_prompt_bytes(
|
|
Path::new("in-memory-image"),
|
|
original_bytes,
|
|
PromptImageMode::ResizeToFit,
|
|
)
|
|
.expect("process image");
|
|
|
|
assert_eq!((processed.width, processed.height), (2048, 2));
|
|
|
|
let mut decoder = ImageReader::with_format(Cursor::new(&processed.bytes), format)
|
|
.into_decoder()
|
|
.expect("create decoder");
|
|
assert_eq!(
|
|
(
|
|
decoder.dimensions(),
|
|
decoder.orientation().expect("read orientation"),
|
|
decoder.icc_profile().expect("read ICC profile"),
|
|
decoder.exif_metadata().expect("read EXIF metadata"),
|
|
),
|
|
(
|
|
(2048, 2),
|
|
Orientation::Rotate90,
|
|
Some(TEST_RGB_ICC_PROFILE.to_vec()),
|
|
Some(ROTATE_90_EXIF.to_vec()),
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn resizing_drops_non_rgb_icc_profile() {
|
|
let image = ImageBuffer::from_pixel(2050, 2, Rgba([200u8, 10, 10, 255]));
|
|
let original_bytes =
|
|
image_bytes_with_metadata(&image, ImageFormat::Jpeg, TEST_CMYK_ICC_PROFILE);
|
|
|
|
let processed = load_for_prompt_bytes(
|
|
Path::new("in-memory-image"),
|
|
original_bytes,
|
|
PromptImageMode::ResizeToFit,
|
|
)
|
|
.expect("process image");
|
|
|
|
let mut decoder = ImageReader::with_format(Cursor::new(&processed.bytes), ImageFormat::Jpeg)
|
|
.into_decoder()
|
|
.expect("create decoder");
|
|
assert_eq!(
|
|
(
|
|
decoder.icc_profile().expect("read ICC profile"),
|
|
decoder.exif_metadata().expect("read EXIF metadata"),
|
|
),
|
|
(None, Some(ROTATE_90_EXIF.to_vec()))
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn preserves_large_image_in_original_mode() {
|
|
let image = ImageBuffer::from_pixel(4096, 2048, Rgba([180u8, 30, 30, 255]));
|
|
let original_bytes = image_bytes(&image, ImageFormat::Png);
|
|
|
|
let processed = load_for_prompt_bytes(
|
|
Path::new("in-memory-image"),
|
|
original_bytes.clone(),
|
|
PromptImageMode::Original,
|
|
)
|
|
.expect("process image");
|
|
|
|
assert_eq!(processed.width, 4096);
|
|
assert_eq!(processed.height, 2048);
|
|
assert_eq!(processed.mime, "image/png");
|
|
assert_eq!(processed.bytes.as_ref(), original_bytes);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn data_url_processing_preserves_supported_source_bytes() {
|
|
let image = ImageBuffer::from_pixel(64, 32, Rgba([10u8, 20, 30, 255]));
|
|
let original_bytes = image_bytes(&image, ImageFormat::Png);
|
|
let image_url = data_url_from_bytes("image/png", &original_bytes)
|
|
.replacen("data:", "DATA:", 1)
|
|
.replacen(";base64,", ";BASE64,", 1);
|
|
|
|
let processed = load_data_url_for_prompt(&image_url, PromptImageMode::ResizeToFit)
|
|
.expect("process data URL image");
|
|
|
|
assert_eq!(processed.width, 64);
|
|
assert_eq!(processed.height, 32);
|
|
assert_eq!(processed.mime, "image/png");
|
|
assert_eq!(processed.bytes.as_ref(), original_bytes);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn data_url_processing_converts_gif_to_png() {
|
|
let image = ImageBuffer::from_pixel(64, 32, Rgba([10u8, 20, 30, 255]));
|
|
let gif_bytes = image_bytes(&image, ImageFormat::Gif);
|
|
let image_url = data_url_from_bytes("image/gif", &gif_bytes);
|
|
|
|
let processed = load_data_url_for_prompt(&image_url, PromptImageMode::ResizeToFit)
|
|
.expect("process GIF data URL");
|
|
|
|
assert_eq!(processed.mime, "image/png");
|
|
assert_eq!(
|
|
image::guess_format(&processed.bytes).expect("detect processed format"),
|
|
ImageFormat::Png
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn data_url_processing_rejects_malformed_input() {
|
|
for image_url in [
|
|
"image/png;base64,AAAA",
|
|
"data:image/png;base64",
|
|
"data:image/png,AAAA",
|
|
"data:image/png;base64,not base64",
|
|
] {
|
|
assert!(matches!(
|
|
load_data_url_for_prompt(image_url, PromptImageMode::ResizeToFit),
|
|
Err(ImageProcessingError::InvalidDataUrl { .. })
|
|
));
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn resize_with_limits_respects_dimension_and_patch_budgets() {
|
|
let image = ImageBuffer::from_pixel(2048, 2048, Rgba([200u8, 10, 10, 255]));
|
|
let original_bytes = image_bytes(&image, ImageFormat::Png);
|
|
let limits = PromptImageResizeLimits {
|
|
max_dimension: 2048,
|
|
max_patches: 2_500,
|
|
};
|
|
|
|
let processed = load_for_prompt_bytes(
|
|
Path::new("in-memory-image"),
|
|
original_bytes,
|
|
PromptImageMode::ResizeWithLimits(limits),
|
|
)
|
|
.expect("process image with explicit limits");
|
|
|
|
assert_eq!((processed.width, processed.height), (1600, 1600));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn fails_cleanly_for_invalid_images() {
|
|
let err = load_for_prompt_bytes(
|
|
Path::new("in-memory-image"),
|
|
b"not an image".to_vec(),
|
|
PromptImageMode::ResizeToFit,
|
|
)
|
|
.expect_err("invalid image should fail");
|
|
assert!(matches!(
|
|
err,
|
|
ImageProcessingError::Decode { .. } | ImageProcessingError::UnsupportedImageFormat { .. }
|
|
));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn reprocesses_updated_file_contents() {
|
|
IMAGE_CACHE.clear();
|
|
|
|
let first_image = ImageBuffer::from_pixel(32, 16, Rgba([20u8, 120, 220, 255]));
|
|
let first_bytes = image_bytes(&first_image, ImageFormat::Png);
|
|
|
|
let first = load_for_prompt_bytes(
|
|
Path::new("in-memory-image"),
|
|
first_bytes,
|
|
PromptImageMode::ResizeToFit,
|
|
)
|
|
.expect("process first image");
|
|
|
|
let second_image = ImageBuffer::from_pixel(96, 48, Rgba([50u8, 60, 70, 255]));
|
|
let second_bytes = image_bytes(&second_image, ImageFormat::Png);
|
|
|
|
let second = load_for_prompt_bytes(
|
|
Path::new("in-memory-image"),
|
|
second_bytes,
|
|
PromptImageMode::ResizeToFit,
|
|
)
|
|
.expect("process updated image");
|
|
|
|
assert_eq!(first.width, 32);
|
|
assert_eq!(first.height, 16);
|
|
assert_eq!(second.width, 96);
|
|
assert_eq!(second.height, 48);
|
|
assert_ne!(second.bytes, first.bytes);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn bounds_cache_by_encoded_byte_size() {
|
|
let cache = ImageCache::new(NonZeroUsize::new(4).expect("non-zero cache capacity"));
|
|
let key = |digest_byte| ImageCacheKey {
|
|
digest: [digest_byte; 20],
|
|
mode: PromptImageMode::Original,
|
|
};
|
|
let image = |size| EncodedImage {
|
|
bytes: vec![0; size].into(),
|
|
mime: "image/png".to_string(),
|
|
width: 1,
|
|
height: 1,
|
|
};
|
|
|
|
cache_image(&cache, key(1), image(3), /*byte_capacity*/ 5);
|
|
cache_image(&cache, key(2), image(3), /*byte_capacity*/ 5);
|
|
cache_image(&cache, key(3), image(6), /*byte_capacity*/ 5);
|
|
|
|
assert!(cache.get(&key(1)).is_none());
|
|
assert!(cache.get(&key(2)).is_some());
|
|
assert!(cache.get(&key(3)).is_none());
|
|
}
|