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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum OutputFormat {
13 #[default]
15 Jpeg,
16 WebP,
18 Avif,
20}
21
22#[derive(Clone, Debug)]
24pub struct ProcessConfig {
25 pub quality: u8,
27 pub tile_size: u32,
29 pub crop: bool,
31 pub bg_tolerance: u8,
33 pub output_format: OutputFormat,
35 pub target_model: Option<VisionModel>,
37 pub max_tiles: Option<u32>,
39 pub max_tokens: Option<u32>,
42 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#[derive(Clone, Copy, Debug)]
116pub enum VisionModel {
117 Claude,
119 ClaudeStandard,
121 Gpt6,
123 Gpt4o,
125 Gpt5,
127 Gemini15,
129 LlamaVision,
132 QwenVl,
134 DeepseekVl,
136 DeepseekFlash,
138 KimiVision,
140 GenericVision,
143}
144
145impl VisionModel {
146 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
240pub 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 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 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 let (w, h) = fit_within(width, height, 1120); 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 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 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; let local = (nh * H) * (nw * H + 1);
369 TokenEstimate {
370 model,
371 tiles: nw * nh + 1, tokens: global + local,
373 }
374 }
375 VisionModel::DeepseekFlash => TokenEstimate {
376 model,
377 tiles: 1,
378 tokens: 384,
379 },
380 VisionModel::KimiVision => {
381 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 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
405pub 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
417pub 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
433fn 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
451pub 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 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 optimal_for_prescaling_model(width, height, 1120, 560)
473 }
474 VisionModel::QwenVl => {
475 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 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
516fn 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 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 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
541pub 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
598pub 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 Standard,
617 Ocr,
619 #[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 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 }
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
680pub 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 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 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
774fn 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
835pub fn calculate_optimal_dimensions(width: u32, height: u32) -> DimensionResult {
839 calculate_optimal_dimensions_with(width, height, 512)
840}
841
842pub 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
870pub 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]; 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
937pub 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
1000pub 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
1056pub 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 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
1102pub 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
1147const MAX_B64_LEN: usize = 64 * 1024 * 1024; const MAX_PIXELS: u64 = 100_000_000; const 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); 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
1187pub 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 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
1224pub 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
1236pub 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#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1275#[serde(rename_all = "lowercase", tag = "op")]
1276pub enum ImageOp {
1277 Crop {
1279 x: u32,
1280 y: u32,
1281 width: u32,
1282 height: u32,
1283 },
1284 Grayscale,
1286 Binarize { threshold: Option<u8> },
1288 Resize { width: u32, height: u32 },
1290 Contrast { amount: f32 },
1292 Brightness { amount: f32 },
1294}
1295
1296pub 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 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); assert_eq!(r.tiles_before, 2 * 2); assert_eq!(r.tiles_after, 1 * 2); }
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); let gpt6 = estimate_tokens(1024, 1024, VisionModel::Gpt6);
1403 assert_eq!(gpt6.tokens, 1229); let large_gpt6 = estimate_tokens(2048, 2048, VisionModel::Gpt6);
1406 assert_eq!(large_gpt6.tiles, 2500); 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 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 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 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 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 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#[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 (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}