Skip to main content

vision_squeezer/
lib.rs

1use std::io::Cursor;
2
3use base64::{Engine, engine::general_purpose::STANDARD as B64};
4use chrono::Utc;
5use image::{DynamicImage, ImageBuffer, Luma, imageops::FilterType};
6use rusqlite::{Connection, params};
7use std::path::PathBuf;
8// ── Config ────────────────────────────────────────────────────────────────────
9
10/// Output encoding format.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum OutputFormat {
13    /// JPEG at configured quality (default).
14    #[default]
15    Jpeg,
16    /// WebP at configured quality — typically 30-50% smaller than JPEG at equal quality.
17    WebP,
18    /// AVIF at configured quality — typically 20-50% smaller than WebP at equal quality.
19    Avif,
20}
21
22/// All tuneable knobs for the pipeline.
23#[derive(Clone, Debug)]
24pub struct ProcessConfig {
25    /// Output quality 1–100 (default 75). Applies to both JPEG and WebP.
26    pub quality: u8,
27    /// LLM patch size in pixels. Overridden when `target_model` is set.
28    pub tile_size: u32,
29    /// Remove solid-color padding borders before resizing (default true).
30    pub crop: bool,
31    /// Max channel delta to treat a pixel as background (default 15).
32    pub bg_tolerance: u8,
33    /// Output encoding format (default: JPEG).
34    pub output_format: OutputFormat,
35    /// When set, resizing is model-aware (accounts for pre-scaling behavior).
36    pub target_model: Option<VisionModel>,
37    /// Limit the maximum number of tiles the output image can consume.
38    pub max_tiles: Option<u32>,
39    /// Token budget for the output image, measured with the target model
40    /// (Claude when unset). The image is downscaled until it fits; 0 disables.
41    pub max_tokens: Option<u32>,
42    /// Use saliency (edge-energy) based crop instead of corner-tolerance crop.
43    pub smart_crop: bool,
44}
45
46impl Default for ProcessConfig {
47    fn default() -> Self {
48        Self {
49            quality: 75,
50            tile_size: 512,
51            crop: true,
52            bg_tolerance: 15,
53            output_format: OutputFormat::Jpeg,
54            target_model: None,
55            max_tiles: None,
56            max_tokens: None,
57            smart_crop: false,
58        }
59    }
60}
61
62impl ProcessConfig {
63    pub fn builder() -> ProcessConfigBuilder {
64        ProcessConfigBuilder(Self::default())
65    }
66}
67
68pub struct ProcessConfigBuilder(ProcessConfig);
69
70impl ProcessConfigBuilder {
71    pub fn quality(mut self, q: u8) -> Self {
72        self.0.quality = q.clamp(1, 100);
73        self
74    }
75    pub fn tile_size(mut self, t: u32) -> Self {
76        self.0.tile_size = t.max(1);
77        self
78    }
79    pub fn crop(mut self, c: bool) -> Self {
80        self.0.crop = c;
81        self
82    }
83    pub fn bg_tolerance(mut self, t: u8) -> Self {
84        self.0.bg_tolerance = t;
85        self
86    }
87    pub fn output_format(mut self, f: OutputFormat) -> Self {
88        self.0.output_format = f;
89        self
90    }
91    pub fn target_model(mut self, m: VisionModel) -> Self {
92        self.0.target_model = Some(m);
93        self
94    }
95    pub fn max_tiles(mut self, m: u32) -> Self {
96        self.0.max_tiles = Some(m);
97        self
98    }
99    pub fn max_tokens(mut self, t: u32) -> Self {
100        self.0.max_tokens = Some(t);
101        self
102    }
103    pub fn smart_crop(mut self, b: bool) -> Self {
104        self.0.smart_crop = b;
105        self
106    }
107    pub fn build(self) -> ProcessConfig {
108        self.0
109    }
110}
111
112// ── Token Estimation ──────────────────────────────────────────────────────────
113
114/// Supported vision model families with exact or explicitly advisory profiles.
115#[derive(Clone, Copy, Debug)]
116pub enum VisionModel {
117    /// Claude 4.7+ high-resolution vision: 28×28 patches, 2576px edge / 4784-token budget.
118    Claude,
119    /// Earlier Claude vision models: 28×28 patches, 1568px edge / 1568-token budget.
120    ClaudeStandard,
121    /// Current OpenAI vision models (GPT-6 / GPT-5.6): 32×32 patches with a 1.2 multiplier.
122    Gpt6,
123    /// GPT-4o / GPT-4.5 high detail: fits in 2048x2048, scales short side to 768, then 512x512 tiles.
124    Gpt4o,
125    /// Legacy GPT-5/5.1 high detail: 70 base tokens + 140 per 512×512 tile.
126    Gpt5,
127    /// Gemini 2.0/3.0: flat 258 tokens if ≤ 384x384, else 258 per 768x768 tile.
128    Gemini15,
129    /// Meta Llama 3.2/3.3 Vision (Mllama): 560×560 tiles, aspect-ratio canvas capped at 4 tiles.
130    /// (Llama 4 uses a different native-multimodal vision encoder and is not modeled by this arm.)
131    LlamaVision,
132    /// Alibaba Qwen2-VL / Qwen2.5-VL: 28px effective grid (14px ViT patch × 2×2 merge), token clamp.
133    QwenVl,
134    /// DeepSeek-VL2: 384×384 base + dynamic 384 local tiles (open-weights; value is local-context savings).
135    DeepseekVl,
136    /// DeepSeek Flash API: current multimodal endpoint, capped at 384 image tokens per image.
137    DeepseekFlash,
138    /// Kimi K2.5/K2.6/K3 native vision. Moonshot does not publish a fixed billing grid; estimate is advisory.
139    KimiVision,
140    /// Popular open/API vision families without a stable public billing grid.
141    /// Estimates are advisory; resizing remains useful and deterministic.
142    GenericVision,
143}
144
145impl VisionModel {
146    /// Parse the model aliases accepted by the CLI, MCP server, and Python binding.
147    pub fn parse(value: &str) -> Option<Self> {
148        match value.to_ascii_lowercase().as_str() {
149            "claude" | "claude-high" | "claude-4.7" => Some(Self::Claude),
150            "claude-standard" => Some(Self::ClaudeStandard),
151            "openai" | "gpt6" | "gpt-6" | "gpt6-astra" | "gpt-6-astra" | "gpt5.6" | "gpt-5.6"
152            | "gpt5.5" | "gpt-5.5" => Some(Self::Gpt6),
153            "gpt4o" | "gpt-4o" => Some(Self::Gpt4o),
154            "gpt5" | "gpt-5" | "gpt5.1" | "gpt-5.1" => Some(Self::Gpt5),
155            "gemini" | "gemini-3" | "gemini-3.8" => Some(Self::Gemini15),
156            "llama" | "llama-vision" => Some(Self::LlamaVision),
157            "qwen" | "qwen-vl" | "qwen3-vl" => Some(Self::QwenVl),
158            "deepseek-local" | "deepseek-vl" | "deepseek-vl2" => Some(Self::DeepseekVl),
159            "deepseek" | "deepseek-flash" | "deepseek-v4-flash-vision-exp" => {
160                Some(Self::DeepseekFlash)
161            }
162            "kimi" | "kimi-vision" | "kimi-k2.5" | "kimi-k2.6" | "kimi-k3" => {
163                Some(Self::KimiVision)
164            }
165            "glm"
166            | "glm-4v"
167            | "glm-4.5v"
168            | "glm-5.3-flash"
169            | "mistral"
170            | "pixtral"
171            | "pixtral-large"
172            | "pixtral-12b"
173            | "gemma"
174            | "gemma-3"
175            | "gemma-4"
176            | "gemma-4-31b"
177            | "internvl"
178            | "internvl2"
179            | "internvl2.5"
180            | "internvl3"
181            | "minicpm"
182            | "minicpm-v"
183            | "minicpm-o"
184            | "molmo"
185            | "molmo2"
186            | "aya"
187            | "aya-vision"
188            | "phi4"
189            | "phi-4"
190            | "phi-4-multimodal"
191            | "granite"
192            | "granite-vision"
193            | "llava"
194            | "llava-onevision"
195            | "llava-next"
196            | "falcon"
197            | "falcon-vision"
198            | "falcon-ocr"
199            | "minimax"
200            | "minimax-vl"
201            | "minimax-m3"
202            | "step"
203            | "step-3.7"
204            | "step-3.7-flash"
205            | "ling"
206            | "ling-vision"
207            | "ling-3.0-flash-vl"
208            | "voyage"
209            | "voyage-multimodal"
210            | "voyage-multimodal-3.5" => Some(Self::GenericVision),
211            _ => None,
212        }
213    }
214
215    pub fn display_name(self) -> &'static str {
216        match self {
217            Self::Claude => "Claude 4.7+",
218            Self::ClaudeStandard => "Claude (standard)",
219            Self::Gpt6 => "GPT-6 / GPT-5.6",
220            Self::Gpt4o => "GPT-4o",
221            Self::Gpt5 => "GPT-5 / 5.1 (legacy)",
222            Self::Gemini15 => "Gemini 3",
223            Self::LlamaVision => "Llama Vision",
224            Self::QwenVl => "Qwen-VL",
225            Self::DeepseekVl => "DeepSeek-VL",
226            Self::DeepseekFlash => "DeepSeek Flash",
227            Self::KimiVision => "Kimi Vision",
228            Self::GenericVision => "Generic vision (advisory)",
229        }
230    }
231}
232
233#[derive(Debug)]
234pub struct TokenEstimate {
235    pub model: VisionModel,
236    pub tokens: u32,
237    pub tiles: u32,
238}
239
240/// Estimate LLM vision tokens for an image of given dimensions.
241pub fn estimate_tokens(width: u32, height: u32, model: VisionModel) -> TokenEstimate {
242    match model {
243        VisionModel::Claude => {
244            let (w, h) = fit_within_patch_budget(width, height, 2576, 4784, 28);
245            let patches = patch_count(w, h, 28);
246            TokenEstimate {
247                model,
248                tiles: patches,
249                tokens: patches,
250            }
251        }
252        VisionModel::ClaudeStandard => {
253            let (w, h) = fit_within_patch_budget(width, height, 1568, 1568, 28);
254            let patches = patch_count(w, h, 28);
255            TokenEstimate {
256                model,
257                tiles: patches,
258                tokens: patches,
259            }
260        }
261        VisionModel::Gpt6 => {
262            let (w, h) = fit_within_patch_budget(width, height, 2048, 2500, 32);
263            let patches = patch_count(w, h, 32);
264            TokenEstimate {
265                model,
266                tiles: patches,
267                tokens: (patches * 12).div_ceil(10),
268            }
269        }
270        VisionModel::Gpt4o => {
271            // GPT-4o / 4.5: fit within 2048x2048, then short side scaled to 768px, then 512x512 tiles.
272            let (mut w, mut h) = fit_within(width, height, 2048);
273            let short_side = w.min(h);
274            if short_side > 768 {
275                let scale = 768.0 / short_side as f64;
276                w = (w as f64 * scale).round() as u32;
277                h = (h as f64 * scale).round() as u32;
278            }
279            let tiles = tile_count(w, 512) * tile_count(h, 512);
280            TokenEstimate {
281                model,
282                tiles,
283                tokens: 85 + tiles * 170,
284            }
285        }
286        VisionModel::Gpt5 => {
287            let (mut w, mut h) = fit_within(width, height, 2048);
288            let short_side = w.min(h);
289            if short_side > 768 {
290                let scale = 768.0 / short_side as f64;
291                w = (w as f64 * scale).round() as u32;
292                h = (h as f64 * scale).round() as u32;
293            }
294            let tiles = tile_count(w, 512) * tile_count(h, 512);
295            TokenEstimate {
296                model,
297                tiles,
298                tokens: 70 + tiles * 140,
299            }
300        }
301        VisionModel::Gemini15 => {
302            // Gemini 2026: flat 258 if <= 384x384, else 768x768 tiles.
303            if width <= 384 && height <= 384 {
304                TokenEstimate {
305                    model,
306                    tiles: 1,
307                    tokens: 258,
308                }
309            } else {
310                let tiles = tile_count(width, 768) * tile_count(height, 768);
311                TokenEstimate {
312                    model,
313                    tiles,
314                    tokens: tiles * 258,
315                }
316            }
317        }
318        VisionModel::LlamaVision => {
319            // Meta Llama 3.2 / 3.3 Vision (Mllama): aspect-ratio canvas of 560×560 tiles, capped at
320            // max_num_tiles = 4. No separate global-thumbnail tile — the canvas is the full
321            // representation. 14px ViT patch → 40×40 = 1600 patches + 1 CLS = 1601 tokens per tile.
322            // Source: transformers MllamaVisionConfig (image_size 560, patch_size 14, max_num_tiles 4).
323            let (w, h) = fit_within(width, height, 1120); // 2×2 max canvas (4 tiles)
324            let tiles = (tile_count(w, 560) * tile_count(h, 560)).clamp(1, 4);
325            TokenEstimate {
326                model,
327                tiles,
328                tokens: tiles * 1601,
329            }
330        }
331        VisionModel::QwenVl => {
332            // Alibaba Qwen2-VL / Qwen2.5-VL: 28px effective grid (image_patch_size 14 × spatial_merge 2).
333            // smart_resize rounds each side to a multiple of 28 and bounds total tokens to
334            // [IMAGE_MIN_TOKEN_NUM, IMAGE_MAX_TOKEN_NUM] = [4, 16384] (qwen_vl_utils defaults).
335            // tokens = (W/28)·(H/28). DashScope endpoints may cap lower via a per-request max_pixels.
336            let (w, h) = fit_within_pixels(width, height, u32::MAX, 16_384 * 28 * 28);
337            let patches = tile_count(w, 28) * tile_count(h, 28);
338            TokenEstimate {
339                model,
340                tiles: patches,
341                tokens: patches.clamp(4, 16_384),
342            }
343        }
344        VisionModel::DeepseekVl => {
345            // DeepSeek-VL2 (open weights — deepseek public API is text-only, so the win is local
346            // inference context, not API billing). SigLIP-SO400M-patch14-384 emits 27×27 patches per
347            // tile; a 2×2 pixel-shuffle compresses that to 14×14 = 196 tokens (h = 14). Token layout:
348            //   global thumbnail = 14·(14+1) = 210  (one <tile_newline> per row)
349            //   + 1 <view_separator>
350            //   local tiles      = (nh·14)·(nw·14 + 1) over the anyres canvas (m·384, n·384), m·n ≤ 9
351            // Sources: DeepSeek-VL2 paper §2 (arXiv:2412.10302) + processing_deepseek_vl_v2.py.
352            const H: u32 = 14;
353            let (nw, nh) = if width <= 384 && height <= 384 {
354                (1, 1)
355            } else {
356                let mut nw = tile_count(width, 384).max(1);
357                let mut nh = tile_count(height, 384).max(1);
358                while nw * nh > 9 {
359                    if nw >= nh {
360                        nw -= 1;
361                    } else {
362                        nh -= 1;
363                    }
364                }
365                (nw, nh)
366            };
367            let global = H * (H + 1) + 1; // global view + separator
368            let local = (nh * H) * (nw * H + 1);
369            TokenEstimate {
370                model,
371                tiles: nw * nh + 1, // local tiles + global view
372                tokens: global + local,
373            }
374        }
375        VisionModel::DeepseekFlash => TokenEstimate {
376            model,
377            tiles: 1,
378            tokens: 384,
379        },
380        VisionModel::KimiVision => {
381            // Moonshot exposes native-resolution vision but no public image billing grid.
382            // Keep this estimate advisory and use a conservative 28px effective grid.
383            let (w, h) = fit_within(width, height, 4096);
384            let patches = patch_count(w, h, 28);
385            TokenEstimate {
386                model,
387                tiles: patches,
388                tokens: patches,
389            }
390        }
391        VisionModel::GenericVision => {
392            // ponytail: one conservative profile for providers without a stable public grid;
393            // split into provider-specific formulas when billing docs become authoritative.
394            let (w, h) = fit_within(width, height, 2048);
395            let patches = patch_count(w, h, 28);
396            TokenEstimate {
397                model,
398                tiles: patches,
399                tokens: patches,
400            }
401        }
402    }
403}
404
405/// Scale dimensions to fit within `max_side` while preserving aspect ratio.
406pub fn fit_within(width: u32, height: u32, max_side: u32) -> (u32, u32) {
407    if width <= max_side && height <= max_side {
408        return (width, height);
409    }
410    let scale = max_side as f64 / width.max(height) as f64;
411    (
412        (width as f64 * scale) as u32,
413        (height as f64 * scale) as u32,
414    )
415}
416
417/// Scale dimensions to fit within both a max-side limit and a total-pixel limit.
418pub fn fit_within_pixels(width: u32, height: u32, max_side: u32, max_pixels: u64) -> (u32, u32) {
419    let (mut w, mut h) = fit_within(width, height, max_side);
420    let total = w as u64 * h as u64;
421    if total > max_pixels {
422        let scale = (max_pixels as f64 / total as f64).sqrt();
423        w = (w as f64 * scale) as u32;
424        h = (h as f64 * scale) as u32;
425    }
426    (w.max(1), h.max(1))
427}
428
429fn patch_count(width: u32, height: u32, patch: u32) -> u32 {
430    width.max(1).div_ceil(patch) * height.max(1).div_ceil(patch)
431}
432
433/// Apply a model's edge and patch limits while preserving the image aspect ratio.
434fn fit_within_patch_budget(
435    width: u32,
436    height: u32,
437    max_side: u32,
438    max_patches: u32,
439    patch: u32,
440) -> (u32, u32) {
441    let (mut w, mut h) = fit_within(width.max(1), height.max(1), max_side);
442    let patches = patch_count(w, h, patch);
443    if patches > max_patches {
444        let scale = (max_patches as f64 / patches as f64).sqrt();
445        w = ((w as f64 * scale) as u32 / patch * patch).max(patch);
446        h = ((h as f64 * scale) as u32 / patch * patch).max(patch);
447    }
448    (w, h)
449}
450
451/// Compute the optimal dimensions to *send* to a given model to minimize tiles.
452///
453/// For models that pre-scale images, we simulate their scaling,
454/// snap the scaled result to tile boundaries, then invert back to input space.
455pub fn optimal_send_dimensions(width: u32, height: u32, model: VisionModel) -> (u32, u32) {
456    match model {
457        VisionModel::Claude => optimal_for_patch_model(width, height, 2576, 4784, 28),
458        VisionModel::ClaudeStandard => optimal_for_patch_model(width, height, 1568, 1568, 28),
459        VisionModel::Gpt6 => optimal_for_patch_model(width, height, 2048, 2500, 32),
460        VisionModel::Gpt4o => optimal_for_prescaling_model(width, height, 2048, 512),
461        VisionModel::Gpt5 => optimal_for_prescaling_model(width, height, 2048, 512),
462        VisionModel::Gemini15 => {
463            // Gemini uses 768x768 tiles if > 384x384
464            if width <= 384 && height <= 384 {
465                (width, height)
466            } else {
467                optimal_for_prescaling_model(width, height, 4096, 768)
468            }
469        }
470        VisionModel::LlamaVision => {
471            // Snap to the 560px tile grid within the 2×2 (1120px) max canvas to avoid spill-over tiles.
472            optimal_for_prescaling_model(width, height, 1120, 560)
473        }
474        VisionModel::QwenVl => {
475            // Snap each side to the 28px patch grid, after fitting under the max-pixel budget.
476            let (fw, fh) = fit_within_pixels(width, height, u32::MAX, 16_384 * 28 * 28);
477            (
478                snap_to_tile_boundary(fw, 28).max(28),
479                snap_to_tile_boundary(fh, 28).max(28),
480            )
481        }
482        VisionModel::DeepseekVl => {
483            // ≤384 stays as a single tile; otherwise snap to the 384px tile grid.
484            if width <= 384 && height <= 384 {
485                (width, height)
486            } else {
487                optimal_for_prescaling_model(width, height, 1152, 384)
488            }
489        }
490        VisionModel::DeepseekFlash => fit_within(width, height, 2048),
491        VisionModel::KimiVision => {
492            let (w, h) = fit_within(width, height, 4096);
493            (snap_to_tile_boundary(w, 28), snap_to_tile_boundary(h, 28))
494        }
495        VisionModel::GenericVision => {
496            let (w, h) = fit_within(width, height, 2048);
497            (snap_to_tile_boundary(w, 28), snap_to_tile_boundary(h, 28))
498        }
499    }
500}
501
502fn optimal_for_patch_model(
503    width: u32,
504    height: u32,
505    max_side: u32,
506    max_patches: u32,
507    patch: u32,
508) -> (u32, u32) {
509    let (w, h) = fit_within_patch_budget(width, height, max_side, max_patches, patch);
510    (
511        snap_to_tile_boundary(w, patch),
512        snap_to_tile_boundary(h, patch),
513    )
514}
515
516/// For models that pre-scale (GPT-4o, Gemini), find the smallest input dimensions
517/// that, after the model's internal fit-within + tiling, produce the fewest tiles.
518///
519/// Strategy: enumerate candidate tile-grid dimensions (tw*tile, th*tile) that fit
520/// within max_side, compute the input size that would map to each, and pick the
521/// candidate that uses the fewest tiles while preserving the original aspect ratio
522/// as closely as possible.
523fn optimal_for_prescaling_model(width: u32, height: u32, max_side: u32, tile: u32) -> (u32, u32) {
524    let (fw, fh) = fit_within(width, height, max_side);
525
526    // Simply snap the fitted dimensions to the nearest tile boundary
527    let target_w = snap_to_tile_boundary(fw, tile).max(tile);
528    let target_h = snap_to_tile_boundary(fh, tile).max(tile);
529
530    // If image was larger than max_side, scale back to input space
531    if width > max_side || height > max_side {
532        let scale = width.max(height) as f64 / max_side as f64;
533        let opt_w = (target_w as f64 * scale).round() as u32;
534        let opt_h = (target_h as f64 * scale).round() as u32;
535        return (opt_w.max(1), opt_h.max(1));
536    }
537
538    (target_w, target_h)
539}
540
541/// Full token savings report for a before/after dimension pair across all models.
542pub struct TokenSavingsTable {
543    pub claude_before: TokenEstimate,
544    pub claude_after: TokenEstimate,
545    pub gpt6_before: TokenEstimate,
546    pub gpt6_after: TokenEstimate,
547    pub gpt4o_before: TokenEstimate,
548    pub gpt4o_after: TokenEstimate,
549    pub gpt5_before: TokenEstimate,
550    pub gpt5_after: TokenEstimate,
551    pub gemini_before: TokenEstimate,
552    pub gemini_after: TokenEstimate,
553}
554
555pub fn token_savings_table(orig_w: u32, orig_h: u32, opt_w: u32, opt_h: u32) -> TokenSavingsTable {
556    TokenSavingsTable {
557        claude_before: estimate_tokens(orig_w, orig_h, VisionModel::Claude),
558        claude_after: estimate_tokens(opt_w, opt_h, VisionModel::Claude),
559        gpt6_before: estimate_tokens(orig_w, orig_h, VisionModel::Gpt6),
560        gpt6_after: estimate_tokens(opt_w, opt_h, VisionModel::Gpt6),
561        gpt4o_before: estimate_tokens(orig_w, orig_h, VisionModel::Gpt4o),
562        gpt4o_after: estimate_tokens(opt_w, opt_h, VisionModel::Gpt4o),
563        gpt5_before: estimate_tokens(orig_w, orig_h, VisionModel::Gpt5),
564        gpt5_after: estimate_tokens(opt_w, opt_h, VisionModel::Gpt5),
565        gemini_before: estimate_tokens(orig_w, orig_h, VisionModel::Gemini15),
566        gemini_after: estimate_tokens(opt_w, opt_h, VisionModel::Gemini15),
567    }
568}
569
570impl TokenSavingsTable {
571    pub fn print(&self) {
572        println!(
573            "{:<12} {:>8} {:>8} {:>10}",
574            "Model", "Before", "After", "Saved"
575        );
576        println!("{}", "-".repeat(42));
577        self.print_row("Claude 4.7+", &self.claude_before, &self.claude_after);
578        self.print_row("GPT-6", &self.gpt6_before, &self.gpt6_after);
579        self.print_row("GPT-4o", &self.gpt4o_before, &self.gpt4o_after);
580        self.print_row("GPT-5", &self.gpt5_before, &self.gpt5_after);
581        self.print_row("Gemini", &self.gemini_before, &self.gemini_after);
582    }
583
584    fn print_row(&self, name: &str, before: &TokenEstimate, after: &TokenEstimate) {
585        let saved = before.tokens.saturating_sub(after.tokens);
586        let pct = if before.tokens > 0 {
587            saved as f64 / before.tokens as f64 * 100.0
588        } else {
589            0.0
590        };
591        println!(
592            "{:<12} {:>8} {:>8} {:>8} ({:.1}%)",
593            name, before.tokens, after.tokens, saved, pct
594        );
595    }
596}
597
598// ── Types ─────────────────────────────────────────────────────────────────────
599
600pub struct DimensionResult {
601    pub width: u32,
602    pub height: u32,
603    pub tiles_before: u32,
604    pub tiles_after: u32,
605}
606
607impl DimensionResult {
608    pub fn tokens_saved(&self) -> u32 {
609        self.tiles_before.saturating_sub(self.tiles_after)
610    }
611}
612
613#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
614pub enum ProcessMode {
615    /// General LLM vision — JPEG output at configured quality.
616    Standard,
617    /// Text extraction — high-contrast grayscale binarization (Otsu threshold).
618    Ocr,
619    /// Auto-detects if the image is mostly text (monochrome/grayscale).
620    #[default]
621    Auto,
622}
623
624pub fn detect_ocr_mode(img: &DynamicImage) -> bool {
625    let rgb = img.to_rgb8();
626    let mut colorful_count = 0;
627    let mut total_count = 0;
628    // Sample every 4th pixel for speed
629    for (x, y, p) in rgb.enumerate_pixels() {
630        if x % 4 == 0 && y % 4 == 0 {
631            total_count += 1;
632            let min = p[0].min(p[1]).min(p[2]);
633            let max = p[0].max(p[1]).max(p[2]);
634            if max.saturating_sub(min) > 25 {
635                colorful_count += 1;
636            }
637        }
638    }
639    let colorful_ratio = colorful_count as f64 / total_count.max(1) as f64;
640    colorful_ratio < 0.1 // if less than 10% of pixels are colorful, assume OCR
641}
642
643pub struct SavingsReport {
644    pub tiles_before: u32,
645    pub tiles_after: u32,
646    pub tiles_saved: u32,
647    pub bytes_before: Option<u64>,
648    pub bytes_after: Option<u64>,
649}
650
651impl SavingsReport {
652    pub fn size_reduction_pct(&self) -> Option<f64> {
653        match (self.bytes_before, self.bytes_after) {
654            (Some(b), Some(a)) if b > 0 => Some((1.0 - a as f64 / b as f64) * 100.0),
655            _ => None,
656        }
657    }
658
659    pub fn token_reduction_pct(&self) -> f64 {
660        if self.tiles_before == 0 {
661            return 0.0;
662        }
663        self.tiles_saved as f64 / self.tiles_before as f64 * 100.0
664    }
665}
666
667pub struct ProcessResult {
668    pub image: DynamicImage,
669    pub width: u32,
670    pub height: u32,
671    pub report: SavingsReport,
672}
673
674impl ProcessResult {
675    pub fn tokens_saved(&self) -> u32 {
676        self.report.tiles_saved
677    }
678}
679
680// ── Pipeline ──────────────────────────────────────────────────────────────────
681
682/// Full pipeline: [crop] → tile-snap resize → [OCR binarize].
683/// Pass `input_bytes = 0` if unknown (omits file-size from report).
684pub fn process(
685    img: DynamicImage,
686    mode: ProcessMode,
687    input_bytes: u64,
688    cfg: &ProcessConfig,
689) -> ProcessResult {
690    let (orig_w, orig_h) = (img.width(), img.height());
691    // A token budget needs a model to measure against; Claude when none is targeted,
692    // so sizing uses its patch grid instead of 512px tiles (which distort the aspect ratio).
693    let budget_model = cfg.target_model.or(cfg
694        .max_tokens
695        .filter(|&t| t > 0)
696        .map(|_| VisionModel::Claude));
697    let tiles_before = match budget_model {
698        Some(model) => estimate_tokens(orig_w, orig_h, model).tiles,
699        None => tile_count(orig_w, cfg.tile_size) * tile_count(orig_h, cfg.tile_size),
700    };
701
702    let after_crop = if cfg.crop {
703        if cfg.smart_crop {
704            saliency_crop(&img, 16)
705        } else {
706            crop_padding(img, cfg.bg_tolerance)
707        }
708    } else {
709        img
710    };
711    let (mut opt_w, mut opt_h) = match budget_model {
712        Some(model) => optimal_send_dimensions(after_crop.width(), after_crop.height(), model),
713        None => {
714            let d = calculate_optimal_dimensions_with(
715                after_crop.width(),
716                after_crop.height(),
717                cfg.tile_size,
718            );
719            (d.width, d.height)
720        }
721    };
722
723    if let Some(max_t) = cfg.max_tiles {
724        let (nw, nh) =
725            enforce_max_tiles(opt_w, opt_h, max_t, cfg.tile_size, cfg.target_model, false);
726        opt_w = nw;
727        opt_h = nh;
728    }
729    if let Some(max_t) = cfg.max_tokens {
730        let (nw, nh) = enforce_max_tiles(opt_w, opt_h, max_t, cfg.tile_size, budget_model, true);
731        opt_w = nw;
732        opt_h = nh;
733    }
734
735    let tiles_after = match budget_model {
736        Some(model) => {
737            let est = estimate_tokens(opt_w, opt_h, model);
738            est.tiles
739        }
740        None => tile_count(opt_w, cfg.tile_size) * tile_count(opt_h, cfg.tile_size),
741    };
742    let resized = fit_to_grid(&after_crop, opt_w, opt_h);
743
744    // Auto never binarizes: Otsu throws away colour (error highlights, chart series)
745    // and saves bytes only, not tokens. Binarize only when the caller asks for Ocr.
746    let actual_mode = match mode {
747        ProcessMode::Auto => ProcessMode::Standard,
748        m => m,
749    };
750
751    let final_image = match actual_mode {
752        ProcessMode::Standard | ProcessMode::Auto => resized,
753        ProcessMode::Ocr => binarize(resized),
754    };
755
756    ProcessResult {
757        width: final_image.width(),
758        height: final_image.height(),
759        image: final_image,
760        report: SavingsReport {
761            tiles_before,
762            tiles_after,
763            tiles_saved: tiles_before.saturating_sub(tiles_after),
764            bytes_before: if input_bytes > 0 {
765                Some(input_bytes)
766            } else {
767                None
768            },
769            bytes_after: None,
770        },
771    }
772}
773
774/// Resize to the grid-snapped size without stretching: scale uniformly to cover it, then
775/// centre-crop the overflow. Gaps over 5% per axis come from coarse tile grids
776/// (e.g. Llama's 560px), where cropping would cut real content, so those still stretch.
777fn fit_to_grid(img: &DynamicImage, w: u32, h: u32) -> DynamicImage {
778    let s = (w as f64 / img.width() as f64).max(h as f64 / img.height() as f64);
779    let cw = ((img.width() as f64 * s).round() as u32).max(w);
780    let ch = ((img.height() as f64 * s).round() as u32).max(h);
781    if (cw - w) * 20 > cw || (ch - h) * 20 > ch {
782        return img.resize_exact(w, h, FilterType::Lanczos3);
783    }
784    img.resize_exact(cw, ch, FilterType::Lanczos3)
785        .crop_imm((cw - w) / 2, (ch - h) / 2, w, h)
786}
787
788fn enforce_max_tiles(
789    mut width: u32,
790    mut height: u32,
791    max_tiles: u32,
792    default_tile_size: u32,
793    model: Option<VisionModel>,
794    by_tokens: bool,
795) -> (u32, u32) {
796    if max_tiles == 0 {
797        return (width, height);
798    }
799
800    let mut scale = 1.0;
801    let orig_w = width;
802    let orig_h = height;
803
804    loop {
805        let (snapped_w, snapped_h) = match model {
806            Some(m) => optimal_send_dimensions(width, height, m),
807            None => {
808                let d = calculate_optimal_dimensions_with(width, height, default_tile_size);
809                (d.width, d.height)
810            }
811        };
812
813        let tiles = match model {
814            Some(m) => {
815                let est = estimate_tokens(snapped_w, snapped_h, m);
816                if by_tokens { est.tokens } else { est.tiles }
817            }
818            None => {
819                tile_count(snapped_w, default_tile_size) * tile_count(snapped_h, default_tile_size)
820            }
821        };
822
823        if tiles <= max_tiles || scale < 0.1 {
824            return (snapped_w, snapped_h);
825        }
826
827        scale *= 0.98;
828        width = (orig_w as f64 * scale) as u32;
829        height = (orig_h as f64 * scale) as u32;
830        width = width.max(1);
831        height = height.max(1);
832    }
833}
834
835// ── Step 1: Tile-Aware Dimension Calculation ───────────────────────────────────
836
837/// Snap W×H to tile boundaries using default tile size (512).
838pub fn calculate_optimal_dimensions(width: u32, height: u32) -> DimensionResult {
839    calculate_optimal_dimensions_with(width, height, 512)
840}
841
842/// Snap W×H to tile boundaries using a custom tile size.
843pub fn calculate_optimal_dimensions_with(
844    width: u32,
845    height: u32,
846    tile_size: u32,
847) -> DimensionResult {
848    let opt_w = snap_to_tile_boundary(width, tile_size);
849    let opt_h = snap_to_tile_boundary(height, tile_size);
850
851    DimensionResult {
852        width: opt_w,
853        height: opt_h,
854        tiles_before: tile_count(width, tile_size) * tile_count(height, tile_size),
855        tiles_after: tile_count(opt_w, tile_size) * tile_count(opt_h, tile_size),
856    }
857}
858
859fn tile_count(dim: u32, tile_size: u32) -> u32 {
860    dim.div_ceil(tile_size)
861}
862
863fn snap_to_tile_boundary(dim: u32, tile_size: u32) -> u32 {
864    if dim.is_multiple_of(tile_size) {
865        return dim;
866    }
867    ((dim / tile_size) * tile_size).max(tile_size)
868}
869
870// ── Step 2: Semantic Crop (padding removal) ────────────────────────────────────
871
872/// Remove solid-color borders using corner sampling + configurable tolerance.
873pub fn crop_padding(img: DynamicImage, bg_tolerance: u8) -> DynamicImage {
874    let rgba = img.to_rgba8();
875    let (w, h) = rgba.dimensions();
876
877    let corners = [
878        *rgba.get_pixel(0, 0),
879        *rgba.get_pixel(w - 1, 0),
880        *rgba.get_pixel(0, h - 1),
881        *rgba.get_pixel(w - 1, h - 1),
882    ];
883    let bg = corners[0]; // first corner as background reference
884
885    let top = first_non_bg_row(&rgba, bg, bg_tolerance, true);
886    let bottom = first_non_bg_row(&rgba, bg, bg_tolerance, false);
887    let left = first_non_bg_col(&rgba, bg, bg_tolerance, true);
888    let right = first_non_bg_col(&rgba, bg, bg_tolerance, false);
889
890    if top >= bottom || left >= right {
891        return DynamicImage::ImageRgba8(rgba);
892    }
893
894    DynamicImage::ImageRgba8(
895        image::imageops::crop_imm(&rgba, left, top, right - left, bottom - top).to_image(),
896    )
897}
898
899fn is_bg(pixel: image::Rgba<u8>, bg: image::Rgba<u8>, tolerance: u8) -> bool {
900    pixel.0[3] < 10
901        || pixel.0[..3]
902            .iter()
903            .zip(bg.0[..3].iter())
904            .all(|(&a, &b)| a.abs_diff(b) <= tolerance)
905}
906
907fn first_non_bg_row(img: &image::RgbaImage, bg: image::Rgba<u8>, tol: u8, from_top: bool) -> u32 {
908    let (w, h) = img.dimensions();
909    let rows: Box<dyn Iterator<Item = u32>> = if from_top {
910        Box::new(0..h)
911    } else {
912        Box::new((0..h).rev())
913    };
914    for y in rows {
915        if (0..w).any(|x| !is_bg(*img.get_pixel(x, y), bg, tol)) {
916            return y;
917        }
918    }
919    0
920}
921
922fn first_non_bg_col(img: &image::RgbaImage, bg: image::Rgba<u8>, tol: u8, from_left: bool) -> u32 {
923    let (w, h) = img.dimensions();
924    let cols: Box<dyn Iterator<Item = u32>> = if from_left {
925        Box::new(0..w)
926    } else {
927        Box::new((0..w).rev())
928    };
929    for x in cols {
930        if (0..h).any(|y| !is_bg(*img.get_pixel(x, y), bg, tol)) {
931            return x;
932        }
933    }
934    0
935}
936
937// ── Saliency Crop (edge-energy based) ─────────────────────────────────────────
938
939/// Crop to the bounding box of high-energy (edge) pixels.
940/// Uses a Sobel-lite gradient magnitude per luma pixel. Pixels above 2× the
941/// mean gradient energy define the salient region; the bbox is expanded by
942/// `margin` pixels on every side.
943///
944/// Falls back to the input unchanged for uniform images (no salient region).
945pub fn saliency_crop(img: &DynamicImage, margin: u32) -> DynamicImage {
946    let gray = img.to_luma8();
947    let (w, h) = gray.dimensions();
948    if w < 3 || h < 3 {
949        return img.clone();
950    }
951
952    let mut energy = vec![0u32; (w * h) as usize];
953    let mut total: u64 = 0;
954    for y in 1..h - 1 {
955        for x in 1..w - 1 {
956            let l = gray.get_pixel(x - 1, y).0[0] as i32;
957            let r = gray.get_pixel(x + 1, y).0[0] as i32;
958            let t = gray.get_pixel(x, y - 1).0[0] as i32;
959            let b = gray.get_pixel(x, y + 1).0[0] as i32;
960            let e = ((r - l).abs() + (b - t).abs()) as u32;
961            energy[(y * w + x) as usize] = e;
962            total += e as u64;
963        }
964    }
965    let count = (w as u64) * (h as u64);
966    let mean = (total / count.max(1)) as u32;
967    let threshold = mean.saturating_mul(2).max(8);
968
969    let (mut min_x, mut min_y, mut max_x, mut max_y) = (w, h, 0u32, 0u32);
970    for y in 0..h {
971        for x in 0..w {
972            if energy[(y * w + x) as usize] > threshold {
973                if x < min_x {
974                    min_x = x;
975                }
976                if y < min_y {
977                    min_y = y;
978                }
979                if x > max_x {
980                    max_x = x;
981                }
982                if y > max_y {
983                    max_y = y;
984                }
985            }
986        }
987    }
988
989    if min_x >= max_x || min_y >= max_y {
990        return img.clone();
991    }
992
993    let x0 = min_x.saturating_sub(margin);
994    let y0 = min_y.saturating_sub(margin);
995    let x1 = (max_x + 1 + margin).min(w);
996    let y1 = (max_y + 1 + margin).min(h);
997    img.crop_imm(x0, y0, x1 - x0, y1 - y0)
998}
999
1000// ── SSIM + Auto-Quality ───────────────────────────────────────────────────────
1001
1002/// Compute structural-similarity (single-window, luma) between two images.
1003///
1004/// Returns a value in [-1, 1]; 1.0 means identical. Both images are converted
1005/// to grayscale; if dimensions differ, the smaller is rescaled to match the
1006/// larger via Lanczos3.
1007pub fn ssim(a: &DynamicImage, b: &DynamicImage) -> f64 {
1008    let (aw, ah) = (a.width(), a.height());
1009    let (bw, bh) = (b.width(), b.height());
1010    let (target_w, target_h) = (aw.max(bw), ah.max(bh));
1011
1012    let resize_if_needed = |img: &DynamicImage| -> image::GrayImage {
1013        if img.width() == target_w && img.height() == target_h {
1014            img.to_luma8()
1015        } else {
1016            img.resize_exact(target_w, target_h, FilterType::Lanczos3)
1017                .to_luma8()
1018        }
1019    };
1020
1021    let a_luma = resize_if_needed(a);
1022    let b_luma = resize_if_needed(b);
1023
1024    let n = (target_w as u64 * target_h as u64).max(1) as f64;
1025    let (mut sum_a, mut sum_b) = (0f64, 0f64);
1026    for (pa, pb) in a_luma.pixels().zip(b_luma.pixels()) {
1027        sum_a += pa.0[0] as f64;
1028        sum_b += pb.0[0] as f64;
1029    }
1030    let mean_a = sum_a / n;
1031    let mean_b = sum_b / n;
1032
1033    let (mut var_a, mut var_b, mut cov) = (0f64, 0f64, 0f64);
1034    for (pa, pb) in a_luma.pixels().zip(b_luma.pixels()) {
1035        let da = pa.0[0] as f64 - mean_a;
1036        let db = pb.0[0] as f64 - mean_b;
1037        var_a += da * da;
1038        var_b += db * db;
1039        cov += da * db;
1040    }
1041    var_a /= n;
1042    var_b /= n;
1043    cov /= n;
1044
1045    let c1 = (0.01f64 * 255.0).powi(2);
1046    let c2 = (0.03f64 * 255.0).powi(2);
1047    let num = (2.0 * mean_a * mean_b + c1) * (2.0 * cov + c2);
1048    let den = (mean_a.powi(2) + mean_b.powi(2) + c1) * (var_a + var_b + c2);
1049    if den.abs() < f64::EPSILON {
1050        1.0
1051    } else {
1052        num / den
1053    }
1054}
1055
1056/// Binary-search the lowest quality in [`min_q`, `max_q`] that meets a given
1057/// SSIM target against the original. Returns the encoded bytes and the quality used.
1058///
1059/// Used when callers want "automatic" quality: pick the smallest file that still
1060/// passes a perceptual threshold (typically 0.95).
1061pub fn encode_with_auto_quality(
1062    original: &DynamicImage,
1063    cfg: &ProcessConfig,
1064    target_ssim: f64,
1065    min_q: u8,
1066    max_q: u8,
1067) -> Result<(Vec<u8>, u8), String> {
1068    let mut lo = min_q.max(1);
1069    let mut hi = max_q.min(100).max(lo + 1);
1070    let mut best: Option<(Vec<u8>, u8)> = None;
1071
1072    while hi.saturating_sub(lo) > 2 {
1073        let mid = lo + (hi - lo) / 2;
1074        let trial = ProcessConfig {
1075            quality: mid,
1076            ..cfg.clone()
1077        };
1078        let bytes = encode_to_bytes(original, &trial)?;
1079        let decoded = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
1080        let score = ssim(original, &decoded);
1081        if score >= target_ssim {
1082            best = Some((bytes, mid));
1083            hi = mid;
1084        } else {
1085            lo = mid;
1086        }
1087    }
1088
1089    // If no quality met the target during the search, encode at max_q as fallback.
1090    if let Some((b, q)) = best {
1091        Ok((b, q))
1092    } else {
1093        let trial = ProcessConfig {
1094            quality: hi,
1095            ..cfg.clone()
1096        };
1097        let bytes = encode_to_bytes(original, &trial)?;
1098        Ok((bytes, hi))
1099    }
1100}
1101
1102// ── Step 3: OCR Binarization ───────────────────────────────────────────────────
1103
1104pub fn binarize(img: DynamicImage) -> DynamicImage {
1105    let gray = img.to_luma8();
1106    let (w, h) = gray.dimensions();
1107    let threshold = otsu_threshold(&gray);
1108    let binary: ImageBuffer<Luma<u8>, Vec<u8>> = ImageBuffer::from_fn(w, h, |x, y| {
1109        let p = gray.get_pixel(x, y).0[0];
1110        Luma([if p < threshold { 0u8 } else { 255u8 }])
1111    });
1112    DynamicImage::ImageLuma8(binary)
1113}
1114
1115fn otsu_threshold(img: &image::GrayImage) -> u8 {
1116    let mut histogram = [0u32; 256];
1117    for p in img.pixels() {
1118        histogram[p.0[0] as usize] += 1;
1119    }
1120    let total = img.width() * img.height();
1121    let (mut sum, mut sum_bg, mut weight_bg) = (0f64, 0f64, 0f64);
1122    for (i, &h) in histogram.iter().enumerate() {
1123        sum += i as f64 * h as f64;
1124    }
1125    let (mut best_thresh, mut best_var) = (0u8, 0f64);
1126    for (t, &h) in histogram.iter().enumerate() {
1127        weight_bg += h as f64;
1128        if weight_bg == 0.0 {
1129            continue;
1130        }
1131        let weight_fg = total as f64 - weight_bg;
1132        if weight_fg == 0.0 {
1133            break;
1134        }
1135        sum_bg += t as f64 * h as f64;
1136        let mean_bg = sum_bg / weight_bg;
1137        let mean_fg = (sum - sum_bg) / weight_fg;
1138        let var = weight_bg * weight_fg * (mean_bg - mean_fg).powi(2);
1139        if var > best_var {
1140            best_var = var;
1141            best_thresh = t as u8;
1142        }
1143    }
1144    best_thresh
1145}
1146
1147// ── Base64 I/O ────────────────────────────────────────────────────────────────
1148
1149/// Reject decode work that would blow up memory: an attacker-supplied base64
1150/// string can be tiny yet declare enormous dimensions (decompression bomb).
1151const MAX_B64_LEN: usize = 64 * 1024 * 1024; // ~48 MB of raw image bytes
1152const MAX_PIXELS: u64 = 100_000_000; // 100 MP
1153const MAX_DIM: u32 = 16_384;
1154
1155pub fn decode_base64_image(input: &str) -> Result<DynamicImage, String> {
1156    let data = if let Some(c) = input.find(',') {
1157        &input[c + 1..]
1158    } else {
1159        input
1160    };
1161    let data = data.trim();
1162    if data.len() > MAX_B64_LEN {
1163        return Err(format!(
1164            "image base64 exceeds {} MB limit",
1165            MAX_B64_LEN / 1_048_576
1166        ));
1167    }
1168    let bytes = B64.decode(data).map_err(|e| e.to_string())?;
1169
1170    let mut limits = image::Limits::default();
1171    limits.max_image_width = Some(MAX_DIM);
1172    limits.max_image_height = Some(MAX_DIM);
1173    limits.max_alloc = Some(MAX_PIXELS * 4); // RGBA worst case
1174
1175    let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes))
1176        .with_guessed_format()
1177        .map_err(|e| e.to_string())?;
1178    reader.limits(limits);
1179    reader.decode().map_err(|e| e.to_string())
1180}
1181
1182pub fn encode_image_base64(img: &DynamicImage, cfg: &ProcessConfig) -> Result<String, String> {
1183    let bytes = encode_to_bytes(img, cfg)?;
1184    Ok(B64.encode(bytes))
1185}
1186
1187/// Encode image to raw bytes using the configured output format.
1188pub fn encode_to_bytes(img: &DynamicImage, cfg: &ProcessConfig) -> Result<Vec<u8>, String> {
1189    match cfg.output_format {
1190        OutputFormat::Jpeg => {
1191            use image::codecs::jpeg::JpegEncoder;
1192            let mut buf = Cursor::new(Vec::new());
1193            let rgb = img.to_rgb8();
1194            JpegEncoder::new_with_quality(&mut buf, cfg.quality)
1195                .encode_image(&DynamicImage::ImageRgb8(rgb))
1196                .map_err(|e| e.to_string())?;
1197            Ok(buf.into_inner())
1198        }
1199        OutputFormat::WebP => {
1200            let rgb = img.to_rgb8();
1201            let enc = webp::Encoder::from_rgb(rgb.as_raw(), rgb.width(), rgb.height());
1202            let mem = enc.encode(cfg.quality as f32);
1203            Ok(mem.to_vec())
1204        }
1205        OutputFormat::Avif => {
1206            use image::ImageEncoder;
1207            use image::codecs::avif::AvifEncoder;
1208            let mut buf = Cursor::new(Vec::new());
1209            let rgba = img.to_rgba8();
1210            // Speed 6 is a reasonable balance; lower = better compression but slower.
1211            AvifEncoder::new_with_speed_quality(&mut buf, 6, cfg.quality)
1212                .write_image(
1213                    rgba.as_raw(),
1214                    rgba.width(),
1215                    rgba.height(),
1216                    image::ExtendedColorType::Rgba8,
1217                )
1218                .map_err(|e| e.to_string())?;
1219            Ok(buf.into_inner())
1220        }
1221    }
1222}
1223
1224// ── MCP Tool: optimize_image ──────────────────────────────────────────────────
1225
1226pub struct OptimizeResult {
1227    pub optimized_base64: String,
1228    pub report: SavingsReport,
1229    pub original_width: u32,
1230    pub original_height: u32,
1231    pub width: u32,
1232    pub height: u32,
1233    pub optimized_bytes: usize,
1234}
1235
1236/// MCP entry point: base64 in → base64 JPEG out + savings report.
1237pub fn optimize_image(
1238    input_base64: &str,
1239    mode: ProcessMode,
1240    cfg: &ProcessConfig,
1241) -> Result<OptimizeResult, String> {
1242    let img = decode_base64_image(input_base64)?;
1243    let (orig_w, orig_h) = (img.width(), img.height());
1244    let input_bytes = {
1245        let data = if let Some(c) = input_base64.find(',') {
1246            &input_base64[c + 1..]
1247        } else {
1248            input_base64
1249        };
1250        B64.decode(data.trim()).map_err(|e| e.to_string())?.len() as u64
1251    };
1252
1253    let mut result = process(img, mode, input_bytes, cfg);
1254    let bytes = encode_to_bytes(&result.image, cfg)?;
1255    let encoded = B64.encode(&bytes);
1256    result.report.bytes_after = Some(bytes.len() as u64);
1257
1258    Ok(OptimizeResult {
1259        optimized_base64: encoded,
1260        report: result.report,
1261        original_width: orig_w,
1262        original_height: orig_h,
1263        width: result.width,
1264        height: result.height,
1265        optimized_bytes: bytes.len(),
1266    })
1267}
1268
1269// ── Tests ─────────────────────────────────────────────────────────────────────
1270
1271// ── Step 4: Sandbox (Think in Code) ──────────────────────────────────────────
1272
1273/// Atomic image operations for the Sandbox mode.
1274#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1275#[serde(rename_all = "lowercase", tag = "op")]
1276pub enum ImageOp {
1277    /// Crop a specific region: { x, y, width, height }
1278    Crop {
1279        x: u32,
1280        y: u32,
1281        width: u32,
1282        height: u32,
1283    },
1284    /// Convert to grayscale.
1285    Grayscale,
1286    /// Binarize using Otsu's threshold (if threshold is None).
1287    Binarize { threshold: Option<u8> },
1288    /// Resize to exact dimensions.
1289    Resize { width: u32, height: u32 },
1290    /// Adjust contrast (e.g., 2.0 for double contrast).
1291    Contrast { amount: f32 },
1292    /// Adjust brightness (e.g., -20 to darken).
1293    Brightness { amount: f32 },
1294}
1295
1296/// Execute a sequence of operations on an image.
1297pub fn process_with_operations(mut img: DynamicImage, ops: Vec<ImageOp>) -> DynamicImage {
1298    for op in ops {
1299        img = match op {
1300            ImageOp::Crop {
1301                x,
1302                y,
1303                width,
1304                height,
1305            } => img.crop_imm(x, y, width, height),
1306            ImageOp::Grayscale => DynamicImage::ImageLuma8(img.to_luma8()),
1307            ImageOp::Binarize { threshold } => {
1308                let gray = img.to_luma8();
1309                let thr = threshold.unwrap_or(128);
1310                let mut binarized = ImageBuffer::new(gray.width(), gray.height());
1311                for (x, y, p) in gray.enumerate_pixels() {
1312                    let val = if p[0] > thr { 255 } else { 0 };
1313                    binarized.put_pixel(x, y, Luma([val]));
1314                }
1315                DynamicImage::ImageLuma8(binarized)
1316            }
1317            ImageOp::Resize { width, height } => {
1318                img.resize_exact(width, height, FilterType::Lanczos3)
1319            }
1320            ImageOp::Contrast { amount } => img.adjust_contrast(amount),
1321            ImageOp::Brightness { amount } => img.brighten(amount as i32),
1322        };
1323    }
1324    img
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329    use super::*;
1330
1331    fn cfg() -> ProcessConfig {
1332        ProcessConfig::default()
1333    }
1334
1335    #[test]
1336    fn decode_round_trips_small_image() {
1337        let img = DynamicImage::ImageRgb8(ImageBuffer::from_fn(8, 8, |_, _| {
1338            image::Rgb([10u8, 20, 30])
1339        }));
1340        let b64 = encode_image_base64(&img, &cfg()).unwrap();
1341        let decoded = decode_base64_image(&b64).unwrap();
1342        assert_eq!((decoded.width(), decoded.height()), (8, 8));
1343    }
1344
1345    #[test]
1346    fn decode_rejects_oversized_dimensions() {
1347        // Valid, tiny-on-disk image whose width exceeds MAX_DIM — the decompression-bomb shape.
1348        let wide = DynamicImage::ImageRgb8(ImageBuffer::from_fn(MAX_DIM + 1, 1, |_, _| {
1349            image::Rgb([0u8, 0, 0])
1350        }));
1351        let b64 = encode_image_base64(&wide, &cfg()).unwrap();
1352        assert!(decode_base64_image(&b64).is_err());
1353    }
1354
1355    #[test]
1356    fn decode_rejects_garbage() {
1357        assert!(decode_base64_image("not valid base64 !!!").is_err());
1358    }
1359
1360    #[test]
1361    fn exact_boundary_unchanged() {
1362        let r = calculate_optimal_dimensions(1024, 512);
1363        assert_eq!((r.width, r.height), (1024, 512));
1364        assert_eq!(r.tokens_saved(), 0);
1365    }
1366
1367    #[test]
1368    fn one_pixel_over_saves_full_tile_row() {
1369        let r = calculate_optimal_dimensions(1025, 1025);
1370        assert_eq!((r.width, r.height), (1024, 1024));
1371        assert_eq!(r.tiles_before, 9);
1372        assert_eq!(r.tiles_after, 4);
1373        assert_eq!(r.tokens_saved(), 5);
1374    }
1375
1376    #[test]
1377    fn small_image_never_below_one_tile() {
1378        let r = calculate_optimal_dimensions(100, 200);
1379        assert_eq!((r.width, r.height), (512, 512));
1380    }
1381
1382    #[test]
1383    fn mid_boundary_snaps_down() {
1384        let r = calculate_optimal_dimensions(768, 512);
1385        assert_eq!(r.width, 512);
1386        assert_eq!(r.tiles_after, 1);
1387    }
1388
1389    #[test]
1390    fn custom_tile_size_256() {
1391        let r = calculate_optimal_dimensions_with(257, 512, 256);
1392        assert_eq!(r.width, 256); // 257 → snaps down to 256
1393        assert_eq!(r.tiles_before, 2 * 2); // ceil(257/256)*ceil(512/256) = 2*2
1394        assert_eq!(r.tiles_after, 1 * 2); // 256/256 * 512/256 = 1*2
1395    }
1396
1397    #[test]
1398    fn current_patch_models_match_provider_examples() {
1399        let claude = estimate_tokens(1000, 1000, VisionModel::Claude);
1400        assert_eq!(claude.tokens, 1296); // ceil(1000 / 28)^2
1401
1402        let gpt6 = estimate_tokens(1024, 1024, VisionModel::Gpt6);
1403        assert_eq!(gpt6.tokens, 1229); // 1024 patches × 1.2, rounded up
1404
1405        let large_gpt6 = estimate_tokens(2048, 2048, VisionModel::Gpt6);
1406        assert_eq!(large_gpt6.tiles, 2500); // resized to 1600×1600
1407        assert_eq!(large_gpt6.tokens, 3000);
1408    }
1409
1410    #[test]
1411    fn model_aliases_and_legacy_gpt5_pricing_are_stable() {
1412        assert!(matches!(
1413            VisionModel::parse("gpt-5.6"),
1414            Some(VisionModel::Gpt6)
1415        ));
1416        assert!(matches!(
1417            VisionModel::parse("gpt-5.5"),
1418            Some(VisionModel::Gpt6)
1419        ));
1420        assert!(matches!(
1421            VisionModel::parse("claude-standard"),
1422            Some(VisionModel::ClaudeStandard)
1423        ));
1424        assert!(matches!(
1425            VisionModel::parse("kimi-k2.6"),
1426            Some(VisionModel::KimiVision)
1427        ));
1428        assert!(matches!(
1429            VisionModel::parse("deepseek"),
1430            Some(VisionModel::DeepseekFlash)
1431        ));
1432        assert!(matches!(
1433            VisionModel::parse("pixtral"),
1434            Some(VisionModel::GenericVision)
1435        ));
1436        assert!(matches!(
1437            VisionModel::parse("glm-5.3-flash"),
1438            Some(VisionModel::GenericVision)
1439        ));
1440        assert_eq!(
1441            estimate_tokens(1024, 1024, VisionModel::GenericVision).tokens,
1442            1369
1443        );
1444        assert_eq!(
1445            estimate_tokens(4096, 4096, VisionModel::DeepseekFlash).tokens,
1446            384
1447        );
1448        assert_eq!(estimate_tokens(1024, 1024, VisionModel::Gpt5).tokens, 630);
1449    }
1450
1451    #[test]
1452    fn full_pipeline_reduces_tiles() {
1453        use image::{DynamicImage, Rgba, RgbaImage};
1454        let mut img = RgbaImage::from_pixel(1025, 1025, Rgba([255, 255, 255, 255]));
1455        for x in 400..600 {
1456            for y in 400..600 {
1457                img.put_pixel(x, y, Rgba([0, 0, 0, 255]));
1458            }
1459        }
1460        let result = process(
1461            DynamicImage::ImageRgba8(img),
1462            ProcessMode::Standard,
1463            0,
1464            &cfg(),
1465        );
1466        assert!(result.report.tiles_after < result.report.tiles_before);
1467    }
1468
1469    #[test]
1470    fn max_tokens_budget_is_respected() {
1471        let img = DynamicImage::ImageRgb8(ImageBuffer::from_fn(1200, 835, |x, y| {
1472            image::Rgb([(x % 251) as u8, (y % 241) as u8, ((x + y) % 239) as u8])
1473        }));
1474        for budget in [500u32, 900, 1200] {
1475            let cfg = ProcessConfig::builder()
1476                .crop(false)
1477                .max_tokens(budget)
1478                .build();
1479            let r = process(img.clone(), ProcessMode::Standard, 0, &cfg);
1480            let t = estimate_tokens(r.width, r.height, VisionModel::Claude).tokens;
1481            assert!(t <= budget, "budget {budget}: got {t} tokens");
1482            assert!(
1483                t * 10 >= budget * 8,
1484                "budget {budget}: shrunk too far ({t})"
1485            );
1486        }
1487    }
1488
1489    #[test]
1490    fn auto_mode_keeps_colour() {
1491        let mut img = image::RgbImage::from_pixel(600, 400, image::Rgb([30, 30, 34]));
1492        for y in 100..140 {
1493            for x in 100..300 {
1494                img.put_pixel(x, y, image::Rgb([230, 40, 40]));
1495            }
1496        }
1497        let cfg = ProcessConfig::builder().crop(false).build();
1498        let r = process(DynamicImage::ImageRgb8(img), ProcessMode::Auto, 0, &cfg);
1499        let has_red = r.image.to_rgb8().pixels().any(|p| p[0] > 150 && p[1] < 100);
1500        assert!(has_red, "auto mode binarized away the red region");
1501    }
1502
1503    #[test]
1504    fn fit_to_grid_crops_evenly_instead_of_stretching() {
1505        // 1000x700 -> 980x672: uniform scale 0.98, 14 rows cropped (7 top, 7 bottom).
1506        // A marker row at y=100 must land at ~91; a stretch (0.96) would put it at 96.
1507        let mut img = image::RgbImage::from_pixel(1000, 700, image::Rgb([0, 0, 0]));
1508        for x in 0..1000 {
1509            img.put_pixel(x, 100, image::Rgb([255, 255, 255]));
1510        }
1511        let out = fit_to_grid(&DynamicImage::ImageRgb8(img), 980, 672).to_luma8();
1512        assert_eq!((out.width(), out.height()), (980, 672));
1513        let row = (0..672)
1514            .max_by_key(|&y| out.get_pixel(490, y).0[0])
1515            .unwrap();
1516        assert!(
1517            (89..=93).contains(&row),
1518            "marker at row {row}, expected ~91"
1519        );
1520    }
1521
1522    #[test]
1523    fn crop_disabled_preserves_size() {
1524        use image::{DynamicImage, Rgba, RgbaImage};
1525        let img = RgbaImage::from_pixel(1024, 1024, Rgba([255, 255, 255, 255]));
1526        let no_crop = ProcessConfig::builder().crop(false).build();
1527        let result = process(
1528            DynamicImage::ImageRgba8(img),
1529            ProcessMode::Standard,
1530            0,
1531            &no_crop,
1532        );
1533        assert_eq!(result.width, 1024);
1534    }
1535
1536    #[test]
1537    fn crop_removes_white_border() {
1538        use image::{Rgba, RgbaImage};
1539        let mut img = RgbaImage::from_pixel(100, 100, Rgba([255, 255, 255, 255]));
1540        for x in 45..55 {
1541            for y in 45..55 {
1542                img.put_pixel(x, y, Rgba([255, 0, 0, 255]));
1543            }
1544        }
1545        let cropped = crop_padding(DynamicImage::ImageRgba8(img), 15);
1546        assert!(cropped.width() < 100 && cropped.height() < 100);
1547    }
1548
1549    #[test]
1550    fn binarize_produces_only_black_white() {
1551        use image::{DynamicImage, GrayImage, Luma};
1552        let img = GrayImage::from_fn(64, 64, |x, _| Luma([if x < 32 { 50u8 } else { 200u8 }]));
1553        let result = binarize(DynamicImage::ImageLuma8(img)).to_luma8();
1554        for p in result.pixels() {
1555            assert!(p.0[0] == 0 || p.0[0] == 255);
1556        }
1557    }
1558
1559    #[test]
1560    fn ssim_identical_images_is_one() {
1561        use image::{DynamicImage, Rgba, RgbaImage};
1562        let img =
1563            DynamicImage::ImageRgba8(RgbaImage::from_pixel(64, 64, Rgba([128, 128, 128, 255])));
1564        let s = ssim(&img, &img);
1565        assert!((s - 1.0).abs() < 1e-9);
1566    }
1567
1568    #[test]
1569    fn ssim_very_different_images_is_low() {
1570        use image::{DynamicImage, Rgba, RgbaImage};
1571        let black = DynamicImage::ImageRgba8(RgbaImage::from_pixel(64, 64, Rgba([0, 0, 0, 255])));
1572        let white =
1573            DynamicImage::ImageRgba8(RgbaImage::from_pixel(64, 64, Rgba([255, 255, 255, 255])));
1574        let s = ssim(&black, &white);
1575        assert!(s < 0.1, "expected low SSIM, got {s}");
1576    }
1577
1578    #[test]
1579    fn saliency_crop_tightens_around_high_energy_region() {
1580        use image::{DynamicImage, Rgba, RgbaImage};
1581        // Uniform white field with a 200x200 textured block in the middle of a 1000x1000 image.
1582        let mut img = RgbaImage::from_pixel(1000, 1000, Rgba([255, 255, 255, 255]));
1583        for x in 400..600 {
1584            for y in 400..600 {
1585                // checker pattern to generate edge energy
1586                let v = if (x + y) % 2 == 0 { 0 } else { 255 };
1587                img.put_pixel(x, y, Rgba([v, v, v, 255]));
1588            }
1589        }
1590        let dyn_img = DynamicImage::ImageRgba8(img);
1591        let cropped = saliency_crop(&dyn_img, 8);
1592        assert!(cropped.width() < 1000);
1593        assert!(cropped.height() < 1000);
1594        // expect to land near the 200×200 block plus margin
1595        assert!(cropped.width() < 400);
1596        assert!(cropped.height() < 400);
1597    }
1598
1599    #[test]
1600    fn auto_quality_returns_quality_in_range() {
1601        use image::{DynamicImage, Rgba, RgbaImage};
1602        let mut img = RgbaImage::from_pixel(256, 256, Rgba([100, 100, 100, 255]));
1603        for x in 0..256 {
1604            for y in 0..256 {
1605                img.put_pixel(x, y, Rgba([(x % 256) as u8, (y % 256) as u8, 128, 255]));
1606            }
1607        }
1608        let dyn_img = DynamicImage::ImageRgba8(img);
1609        let cfg = ProcessConfig::default();
1610        let (bytes, q) = encode_with_auto_quality(&dyn_img, &cfg, 0.95, 40, 95).expect("ok");
1611        assert!((40..=95).contains(&q));
1612        assert!(!bytes.is_empty());
1613    }
1614
1615    #[test]
1616    fn high_bg_tolerance_crops_more() {
1617        use image::{DynamicImage, Rgba, RgbaImage};
1618        // Corners: pure white [255,255,255]. Border: off-white [240,240,240]. Center: black.
1619        // diff = 15. strict(5): 15 > 5 → border NOT bg → no crop.
1620        // loose(20): 15 ≤ 20 → border IS bg → crops.
1621        let mut img = RgbaImage::from_pixel(100, 100, Rgba([240, 240, 240, 255]));
1622        for corner in [(0u32, 0u32), (99, 0), (0, 99), (99, 99)] {
1623            img.put_pixel(corner.0, corner.1, Rgba([255, 255, 255, 255]));
1624        }
1625        for x in 45..55 {
1626            for y in 45..55 {
1627                img.put_pixel(x, y, Rgba([0, 0, 0, 255]));
1628            }
1629        }
1630        let strict = crop_padding(DynamicImage::ImageRgba8(img.clone()), 5);
1631        let loose = crop_padding(DynamicImage::ImageRgba8(img), 20);
1632        assert!(loose.width() < strict.width());
1633    }
1634}
1635// ── Persistence & Analytics ───────────────────────────────────────────────────
1636
1637#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1638pub struct OptimizationReport {
1639    pub timestamp: String,
1640    pub model: String,
1641    pub original_tokens: u32,
1642    pub optimized_tokens: u32,
1643    pub original_bytes: u64,
1644    pub optimized_bytes: u64,
1645    pub mode: String,
1646}
1647
1648#[derive(Debug, serde::Serialize, serde::Deserialize)]
1649pub struct SqueezerStats {
1650    pub total_optimizations: u64,
1651    pub total_original_tokens: u64,
1652    pub total_optimized_tokens: u64,
1653    pub total_original_bytes: u64,
1654    pub total_optimized_bytes: u64,
1655    pub history: Vec<OptimizationReport>,
1656}
1657
1658impl SqueezerStats {
1659    pub fn total_token_savings(&self) -> u64 {
1660        self.total_original_tokens
1661            .saturating_sub(self.total_optimized_tokens)
1662    }
1663
1664    pub fn total_byte_savings(&self) -> u64 {
1665        self.total_original_bytes
1666            .saturating_sub(self.total_optimized_bytes)
1667    }
1668
1669    pub fn estimated_usd_saved(&self) -> f64 {
1670        // Blended average: $2.50 per 1M tokens (Claude/GPT-4o blend)
1671        (self.total_token_savings() as f64 / 1_000_000.0) * 2.50
1672    }
1673}
1674
1675pub struct Persistence;
1676
1677impl Persistence {
1678    fn get_db_path() -> PathBuf {
1679        let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
1680        path.push(".vision-squeezer");
1681        let _ = std::fs::create_dir_all(&path);
1682        path.push("stats.db");
1683        path
1684    }
1685
1686    pub fn init_db() -> Result<(), String> {
1687        let conn = Connection::open(Self::get_db_path()).map_err(|e| e.to_string())?;
1688        conn.execute(
1689            "CREATE TABLE IF NOT EXISTS optimizations (
1690                id INTEGER PRIMARY KEY AUTOINCREMENT,
1691                timestamp TEXT NOT NULL,
1692                model TEXT NOT NULL,
1693                original_tokens INTEGER NOT NULL,
1694                optimized_tokens INTEGER NOT NULL,
1695                original_bytes INTEGER NOT NULL,
1696                optimized_bytes INTEGER NOT NULL,
1697                mode TEXT NOT NULL
1698            )",
1699            [],
1700        )
1701        .map_err(|e| e.to_string())?;
1702        Ok(())
1703    }
1704
1705    pub fn log_optimization(
1706        model: &str,
1707        orig_tokens: u32,
1708        opt_tokens: u32,
1709        orig_bytes: u64,
1710        opt_bytes: u64,
1711        mode: &str,
1712    ) -> Result<(), String> {
1713        let conn = Connection::open(Self::get_db_path()).map_err(|e| e.to_string())?;
1714        conn.execute(
1715            "INSERT INTO optimizations (timestamp, model, original_tokens, optimized_tokens, original_bytes, optimized_bytes, mode)
1716             VALUES (?, ?, ?, ?, ?, ?, ?)",
1717            params![
1718                Utc::now().to_rfc3339(),
1719                model,
1720                orig_tokens,
1721                opt_tokens,
1722                orig_bytes as i64,
1723                opt_bytes as i64,
1724                mode,
1725            ],
1726        ).map_err(|e| e.to_string())?;
1727        Ok(())
1728    }
1729
1730    pub fn get_stats() -> Result<SqueezerStats, String> {
1731        let conn = Connection::open(Self::get_db_path()).map_err(|e| e.to_string())?;
1732
1733        let mut stmt = conn
1734            .prepare(
1735                "SELECT 
1736                COUNT(*), 
1737                SUM(original_tokens), 
1738                SUM(optimized_tokens), 
1739                SUM(original_bytes), 
1740                SUM(optimized_bytes) 
1741             FROM optimizations",
1742            )
1743            .map_err(|e| e.to_string())?;
1744
1745        let (count, orig_t, opt_t, orig_b, opt_b) = stmt
1746            .query_row([], |row| {
1747                Ok((
1748                    row.get::<_, Option<i64>>(0)?.unwrap_or(0) as u64,
1749                    row.get::<_, Option<i64>>(1)?.unwrap_or(0) as u64,
1750                    row.get::<_, Option<i64>>(2)?.unwrap_or(0) as u64,
1751                    row.get::<_, Option<i64>>(3)?.unwrap_or(0) as u64,
1752                    row.get::<_, Option<i64>>(4)?.unwrap_or(0) as u64,
1753                ))
1754            })
1755            .map_err(|e| e.to_string())?;
1756
1757        let mut stmt = conn.prepare(
1758            "SELECT timestamp, model, original_tokens, optimized_tokens, original_bytes, optimized_bytes, mode 
1759             FROM optimizations ORDER BY timestamp DESC LIMIT 50"
1760        ).map_err(|e| e.to_string())?;
1761
1762        let history = stmt
1763            .query_map([], |row| {
1764                Ok(OptimizationReport {
1765                    timestamp: row.get(0)?,
1766                    model: row.get(1)?,
1767                    original_tokens: row.get(2)?,
1768                    optimized_tokens: row.get(3)?,
1769                    original_bytes: row.get::<_, i64>(4)? as u64,
1770                    optimized_bytes: row.get::<_, i64>(5)? as u64,
1771                    mode: row.get(6)?,
1772                })
1773            })
1774            .map_err(|e| e.to_string())?
1775            .collect::<Result<Vec<_>, _>>()
1776            .map_err(|e| e.to_string())?;
1777
1778        Ok(SqueezerStats {
1779            total_optimizations: count,
1780            total_original_tokens: orig_t,
1781            total_optimized_tokens: opt_t,
1782            total_original_bytes: orig_b,
1783            total_optimized_bytes: opt_b,
1784            history,
1785        })
1786    }
1787
1788    pub fn get_all_history() -> Result<Vec<OptimizationReport>, String> {
1789        let conn = Connection::open(Self::get_db_path()).map_err(|e| e.to_string())?;
1790        let mut stmt = conn.prepare(
1791            "SELECT timestamp, model, original_tokens, optimized_tokens, original_bytes, optimized_bytes, mode
1792             FROM optimizations ORDER BY timestamp ASC"
1793        ).map_err(|e| e.to_string())?;
1794
1795        stmt.query_map([], |row| {
1796            Ok(OptimizationReport {
1797                timestamp: row.get(0)?,
1798                model: row.get(1)?,
1799                original_tokens: row.get(2)?,
1800                optimized_tokens: row.get(3)?,
1801                original_bytes: row.get::<_, i64>(4)? as u64,
1802                optimized_bytes: row.get::<_, i64>(5)? as u64,
1803                mode: row.get(6)?,
1804            })
1805        })
1806        .map_err(|e| e.to_string())?
1807        .collect::<Result<Vec<_>, _>>()
1808        .map_err(|e| e.to_string())
1809    }
1810}