1use base64::{Engine as _, engine::general_purpose::STANDARD};
45use serde_json::Value;
46use std::sync::atomic::{AtomicU64, Ordering};
47
48#[derive(Debug, Clone)]
50pub struct ImageCompressionConfig {
51 pub enabled: bool,
53 pub max_dimension: u32,
55 pub quality: u8,
57 pub min_size_bytes: usize,
59}
60
61impl Default for ImageCompressionConfig {
62 fn default() -> Self {
63 Self {
64 enabled: false, max_dimension: 1536,
66 quality: 75,
67 min_size_bytes: 50_000,
68 }
69 }
70}
71
72#[derive(Debug, Clone)]
74pub struct ImageCompressResult {
75 pub data: String,
77 pub media_type: String,
79 pub original_bytes: usize,
81 pub compressed_bytes: usize,
83 pub tokens_saved: usize,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum ImageClass {
90 Screenshot,
92 Diagram,
94 Photo,
96 Unknown,
98}
99
100#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
102pub struct ImageStats {
103 pub images_processed: u64,
104 pub images_compressed: u64,
105 pub bytes_saved: u64,
106 pub tokens_saved: u64,
107}
108
109static IMAGES_PROCESSED: AtomicU64 = AtomicU64::new(0);
110static IMAGES_COMPRESSED: AtomicU64 = AtomicU64::new(0);
111static BYTES_SAVED: AtomicU64 = AtomicU64::new(0);
112static TOKENS_SAVED: AtomicU64 = AtomicU64::new(0);
113
114pub fn compress_anthropic_images(doc: &mut Value, config: &ImageCompressionConfig) -> usize {
117 if !config.enabled {
118 return 0;
119 }
120
121 let Some(messages) = doc.get_mut("messages").and_then(Value::as_array_mut) else {
122 return 0;
123 };
124
125 let mut count = 0;
126 for msg in messages.iter_mut() {
127 if let Some(content) = msg.get_mut("content").and_then(Value::as_array_mut) {
128 for block in content.iter_mut() {
129 if compress_anthropic_image_block(block, config) {
130 count += 1;
131 }
132 }
133 }
134 }
135 count
136}
137
138pub fn compress_openai_images(doc: &mut Value, config: &ImageCompressionConfig) -> usize {
141 if !config.enabled {
142 return 0;
143 }
144
145 let Some(messages) = doc.get_mut("messages").and_then(Value::as_array_mut) else {
146 return 0;
147 };
148
149 let mut count = 0;
150 for msg in messages.iter_mut() {
151 if let Some(content) = msg.get_mut("content").and_then(Value::as_array_mut) {
152 for part in content.iter_mut() {
153 if compress_openai_image_part(part, config) {
154 count += 1;
155 }
156 }
157 }
158 }
159 count
160}
161
162pub fn stats() -> ImageStats {
164 ImageStats {
165 images_processed: IMAGES_PROCESSED.load(Ordering::Relaxed),
166 images_compressed: IMAGES_COMPRESSED.load(Ordering::Relaxed),
167 bytes_saved: BYTES_SAVED.load(Ordering::Relaxed),
168 tokens_saved: TOKENS_SAVED.load(Ordering::Relaxed),
169 }
170}
171
172fn compress_anthropic_image_block(block: &mut Value, config: &ImageCompressionConfig) -> bool {
177 let block_type = block.get("type").and_then(Value::as_str);
178 if block_type != Some("image") {
179 return false;
180 }
181
182 let source = block.get("source");
183 let source_type = source.and_then(|s| s.get("type")).and_then(Value::as_str);
184 if source_type != Some("base64") {
185 return false;
186 }
187
188 let data = source
189 .and_then(|s| s.get("data"))
190 .and_then(Value::as_str)
191 .unwrap_or("");
192 let media_type = source
193 .and_then(|s| s.get("media_type"))
194 .and_then(Value::as_str)
195 .unwrap_or("image/png");
196
197 IMAGES_PROCESSED.fetch_add(1, Ordering::Relaxed);
198
199 let Ok(decoded) = STANDARD.decode(data) else {
200 return false;
201 };
202
203 if decoded.len() < config.min_size_bytes {
204 return false;
205 }
206
207 if let Some(result) = compress_image_bytes(&decoded, media_type, config) {
208 let new_source = serde_json::json!({
209 "type": "base64",
210 "media_type": result.media_type,
211 "data": result.data,
212 });
213 block["source"] = new_source;
214
215 IMAGES_COMPRESSED.fetch_add(1, Ordering::Relaxed);
216 BYTES_SAVED.fetch_add(
217 result
218 .original_bytes
219 .saturating_sub(result.compressed_bytes) as u64,
220 Ordering::Relaxed,
221 );
222 TOKENS_SAVED.fetch_add(result.tokens_saved as u64, Ordering::Relaxed);
223 true
224 } else {
225 false
226 }
227}
228
229fn compress_openai_image_part(part: &mut Value, config: &ImageCompressionConfig) -> bool {
234 let part_type = part.get("type").and_then(Value::as_str);
235 if part_type != Some("image_url") {
236 return false;
237 }
238
239 let image_url = part.get("image_url");
240
241 let detail = image_url
243 .and_then(|iu| iu.get("detail"))
244 .and_then(Value::as_str)
245 .unwrap_or("auto");
246 if detail == "high" || detail == "low" {
247 return false; }
249
250 let url = image_url
251 .and_then(|iu| iu.get("url"))
252 .and_then(Value::as_str)
253 .unwrap_or("");
254
255 if !url.starts_with("data:image/") {
257 return false;
258 }
259
260 IMAGES_PROCESSED.fetch_add(1, Ordering::Relaxed);
261
262 let Some((media_type, data)) = parse_data_uri(url) else {
263 return false;
264 };
265
266 let Ok(decoded) = STANDARD.decode(data) else {
267 return false;
268 };
269
270 if decoded.len() < config.min_size_bytes {
271 return false;
272 }
273
274 if let Some(result) = compress_image_bytes(&decoded, &media_type, config) {
275 let new_url = format!("data:{};base64,{}", result.media_type, result.data);
276 part["image_url"]["url"] = Value::String(new_url);
277
278 IMAGES_COMPRESSED.fetch_add(1, Ordering::Relaxed);
279 BYTES_SAVED.fetch_add(
280 result
281 .original_bytes
282 .saturating_sub(result.compressed_bytes) as u64,
283 Ordering::Relaxed,
284 );
285 TOKENS_SAVED.fetch_add(result.tokens_saved as u64, Ordering::Relaxed);
286 true
287 } else {
288 false
289 }
290}
291
292fn compress_image_bytes(
306 data: &[u8],
307 _media_type: &str,
308 config: &ImageCompressionConfig,
309) -> Option<ImageCompressResult> {
310 let (width, height) = detect_dimensions(data)?;
311 let original_bytes = data.len();
312
313 let (target_w, target_h) = if width > config.max_dimension || height > config.max_dimension {
315 let scale = config.max_dimension as f64 / width.max(height) as f64;
316 (
317 (width as f64 * scale) as u32,
318 (height as f64 * scale) as u32,
319 )
320 } else {
321 if original_bytes < config.min_size_bytes * 2 {
323 return None;
324 }
325 (width, height)
326 };
327
328 let original_tokens = estimate_vision_tokens(width, height);
330 let target_tokens = estimate_vision_tokens(target_w, target_h);
331 let tokens_saved = original_tokens.saturating_sub(target_tokens);
332
333 if tokens_saved < 50 {
334 return None; }
336
337 if width > config.max_dimension || height > config.max_dimension {
346 let encoded = STANDARD.encode(data);
348 return Some(ImageCompressResult {
349 data: encoded,
350 media_type: _media_type.to_string(),
351 original_bytes,
352 compressed_bytes: original_bytes,
353 tokens_saved,
354 });
355 }
356
357 None
358}
359
360fn detect_dimensions(data: &[u8]) -> Option<(u32, u32)> {
362 if data.len() < 24 {
363 return None;
364 }
365
366 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
368 let width = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
369 let height = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
370 return Some((width, height));
371 }
372
373 if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
375 let mut i = 2;
376 while i + 9 < data.len() {
377 if data[i] != 0xFF {
378 i += 1;
379 continue;
380 }
381 let marker = data[i + 1];
382 if (0xC0..=0xC3).contains(&marker) {
384 let height = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
385 let width = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
386 return Some((width, height));
387 }
388 let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
389 i += 2 + len;
390 }
391 }
392
393 if data.len() >= 30 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
395 if &data[12..16] == b"VP8 " && data.len() >= 30 {
396 let width = (u16::from_le_bytes([data[26], data[27]]) & 0x3FFF) as u32;
398 let height = (u16::from_le_bytes([data[28], data[29]]) & 0x3FFF) as u32;
399 return Some((width, height));
400 }
401 if &data[12..16] == b"VP8L" && data.len() >= 25 {
402 let bits = u32::from_le_bytes([data[21], data[22], data[23], data[24]]);
404 let width = (bits & 0x3FFF) + 1;
405 let height = ((bits >> 14) & 0x3FFF) + 1;
406 return Some((width, height));
407 }
408 if &data[12..16] == b"VP8X" && data.len() >= 30 {
409 let width = (u32::from_le_bytes([data[24], data[25], data[26], 0]) & 0xFFFFFF) + 1;
411 let height = (u32::from_le_bytes([data[27], data[28], data[29], 0]) & 0xFFFFFF) + 1;
412 return Some((width, height));
413 }
414 }
415
416 None
417}
418
419fn estimate_vision_tokens(width: u32, height: u32) -> usize {
421 ((width as usize) * (height as usize)) / 750
422}
423
424fn parse_data_uri(uri: &str) -> Option<(String, &str)> {
426 let rest = uri.strip_prefix("data:")?;
427 let semi = rest.find(';')?;
428 let media_type = &rest[..semi];
429 let after_semi = &rest[semi + 1..];
430 let data = after_semi.strip_prefix("base64,")?;
431 Some((media_type.to_string(), data))
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn detect_png_dimensions() {
440 let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; png.extend_from_slice(&[0, 0, 0, 13]); png.extend_from_slice(b"IHDR"); png.extend_from_slice(&100u32.to_be_bytes()); png.extend_from_slice(&50u32.to_be_bytes()); png.extend_from_slice(&[8, 2, 0, 0, 0]); assert_eq!(detect_dimensions(&png), Some((100, 50)));
449 }
450
451 #[test]
452 fn estimate_tokens_1080p() {
453 let tokens = estimate_vision_tokens(1920, 1080);
455 assert_eq!(tokens, 2764); }
457
458 #[test]
459 fn estimate_tokens_resized() {
460 let tokens = estimate_vision_tokens(1024, 576);
462 assert_eq!(tokens, 786); }
465
466 #[test]
467 fn parse_data_uri_valid() {
468 let uri = "data:image/png;base64,iVBORw0KGgo=";
469 let (media, data) = parse_data_uri(uri).unwrap();
470 assert_eq!(media, "image/png");
471 assert_eq!(data, "iVBORw0KGgo=");
472 }
473
474 #[test]
475 fn parse_data_uri_invalid() {
476 assert!(parse_data_uri("https://example.com/img.png").is_none());
477 assert!(parse_data_uri("not-a-data-uri").is_none());
478 }
479
480 #[test]
481 fn config_default_is_opt_in() {
482 let config = ImageCompressionConfig::default();
483 assert!(!config.enabled);
484 assert_eq!(config.max_dimension, 1536);
485 assert_eq!(config.quality, 75);
486 }
487
488 #[test]
489 fn skip_small_images() {
490 let config = ImageCompressionConfig {
491 enabled: true,
492 min_size_bytes: 50_000,
493 ..Default::default()
494 };
495 let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
497 png.extend_from_slice(&[0, 0, 0, 13]);
498 png.extend_from_slice(b"IHDR");
499 png.extend_from_slice(&100u32.to_be_bytes());
500 png.extend_from_slice(&50u32.to_be_bytes());
501 png.extend_from_slice(&[8, 2, 0, 0, 0]);
502 png.resize(1000, 0);
504
505 assert!(compress_image_bytes(&png, "image/png", &config).is_none());
506 }
507
508 #[test]
509 fn openai_respects_detail_high() {
510 let config = ImageCompressionConfig {
511 enabled: true,
512 ..Default::default()
513 };
514 let mut doc = serde_json::json!({
515 "messages": [{
516 "role": "user",
517 "content": [{
518 "type": "image_url",
519 "image_url": {
520 "url": "data:image/png;base64,abc",
521 "detail": "high"
522 }
523 }]
524 }]
525 });
526 assert_eq!(compress_openai_images(&mut doc, &config), 0);
527 }
528
529 #[test]
530 fn openai_skips_remote_urls() {
531 let config = ImageCompressionConfig {
532 enabled: true,
533 ..Default::default()
534 };
535 let mut doc = serde_json::json!({
536 "messages": [{
537 "role": "user",
538 "content": [{
539 "type": "image_url",
540 "image_url": {
541 "url": "https://example.com/img.png"
542 }
543 }]
544 }]
545 });
546 assert_eq!(compress_openai_images(&mut doc, &config), 0);
547 }
548}