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}
19
20#[derive(Clone, Debug)]
22pub struct ProcessConfig {
23 pub quality: u8,
25 pub tile_size: u32,
27 pub crop: bool,
29 pub bg_tolerance: u8,
31 pub output_format: OutputFormat,
33 pub target_model: Option<VisionModel>,
35 pub max_tiles: Option<u32>,
37}
38
39impl Default for ProcessConfig {
40 fn default() -> Self {
41 Self {
42 quality: 75,
43 tile_size: 512,
44 crop: true,
45 bg_tolerance: 15,
46 output_format: OutputFormat::Jpeg,
47 target_model: None,
48 max_tiles: None,
49 }
50 }
51}
52
53impl ProcessConfig {
54 pub fn builder() -> ProcessConfigBuilder {
55 ProcessConfigBuilder(Self::default())
56 }
57}
58
59pub struct ProcessConfigBuilder(ProcessConfig);
60
61impl ProcessConfigBuilder {
62 pub fn quality(mut self, q: u8) -> Self {
63 self.0.quality = q.clamp(1, 100);
64 self
65 }
66 pub fn tile_size(mut self, t: u32) -> Self {
67 self.0.tile_size = t.max(1);
68 self
69 }
70 pub fn crop(mut self, c: bool) -> Self {
71 self.0.crop = c;
72 self
73 }
74 pub fn bg_tolerance(mut self, t: u8) -> Self {
75 self.0.bg_tolerance = t;
76 self
77 }
78 pub fn output_format(mut self, f: OutputFormat) -> Self {
79 self.0.output_format = f;
80 self
81 }
82 pub fn target_model(mut self, m: VisionModel) -> Self {
83 self.0.target_model = Some(m);
84 self
85 }
86 pub fn max_tiles(mut self, m: u32) -> Self {
87 self.0.max_tiles = Some(m);
88 self
89 }
90 pub fn build(self) -> ProcessConfig {
91 self.0
92 }
93}
94
95#[derive(Clone, Copy, Debug)]
99pub enum VisionModel {
100 Claude,
102 Gpt4o,
104 Gpt5,
106 Gemini15,
108}
109
110#[derive(Debug)]
111pub struct TokenEstimate {
112 pub model: VisionModel,
113 pub tokens: u32,
114 pub tiles: u32,
115}
116
117pub fn estimate_tokens(width: u32, height: u32, model: VisionModel) -> TokenEstimate {
119 match model {
120 VisionModel::Claude => {
121 let tokens = ((width as u64 * height as u64) / 750) as u32;
123 TokenEstimate {
124 model,
125 tiles: 1,
126 tokens: tokens.max(85),
127 }
128 }
129 VisionModel::Gpt4o => {
130 let (mut w, mut h) = fit_within(width, height, 2048);
132 let short_side = w.min(h);
133 if short_side > 768 {
134 let scale = 768.0 / short_side as f64;
135 w = (w as f64 * scale).round() as u32;
136 h = (h as f64 * scale).round() as u32;
137 }
138 let tiles = tile_count(w, 512) * tile_count(h, 512);
139 TokenEstimate {
140 model,
141 tiles,
142 tokens: 85 + tiles * 170,
143 }
144 }
145 VisionModel::Gpt5 => {
146 let (w, h) = fit_within_pixels(width, height, 6000, 10_240_000);
147 let tiles = tile_count(w, 512) * tile_count(h, 512);
148 TokenEstimate {
149 model,
150 tiles,
151 tokens: (85 + tiles * 170).min(1536),
152 }
153 }
154 VisionModel::Gemini15 => {
155 if width <= 384 && height <= 384 {
157 TokenEstimate {
158 model,
159 tiles: 1,
160 tokens: 258,
161 }
162 } else {
163 let tiles = tile_count(width, 768) * tile_count(height, 768);
164 TokenEstimate {
165 model,
166 tiles,
167 tokens: tiles * 258,
168 }
169 }
170 }
171 }
172}
173
174pub fn fit_within(width: u32, height: u32, max_side: u32) -> (u32, u32) {
176 if width <= max_side && height <= max_side {
177 return (width, height);
178 }
179 let scale = max_side as f64 / width.max(height) as f64;
180 (
181 (width as f64 * scale) as u32,
182 (height as f64 * scale) as u32,
183 )
184}
185
186pub fn fit_within_pixels(width: u32, height: u32, max_side: u32, max_pixels: u64) -> (u32, u32) {
188 let (mut w, mut h) = fit_within(width, height, max_side);
189 let total = w as u64 * h as u64;
190 if total > max_pixels {
191 let scale = (max_pixels as f64 / total as f64).sqrt();
192 w = (w as f64 * scale) as u32;
193 h = (h as f64 * scale) as u32;
194 }
195 (w.max(1), h.max(1))
196}
197
198pub fn optimal_send_dimensions(width: u32, height: u32, model: VisionModel) -> (u32, u32) {
204 match model {
205 VisionModel::Claude => {
206 (
209 snap_to_tile_boundary(width, 256),
210 snap_to_tile_boundary(height, 256),
211 )
212 }
213 VisionModel::Gpt4o => optimal_for_prescaling_model(width, height, 2048, 512),
214 VisionModel::Gpt5 => {
215 let (fw, fh) = fit_within_pixels(width, height, 6000, 10_240_000);
216 (
217 snap_to_tile_boundary(fw, 512).max(512),
218 snap_to_tile_boundary(fh, 512).max(512),
219 )
220 }
221 VisionModel::Gemini15 => {
222 if width <= 384 && height <= 384 {
224 (width, height)
225 } else {
226 optimal_for_prescaling_model(width, height, 4096, 768)
227 }
228 }
229 }
230}
231
232fn optimal_for_prescaling_model(width: u32, height: u32, max_side: u32, tile: u32) -> (u32, u32) {
240 let (fw, fh) = fit_within(width, height, max_side);
241
242 let target_w = snap_to_tile_boundary(fw, tile).max(tile);
244 let target_h = snap_to_tile_boundary(fh, tile).max(tile);
245
246 if width > max_side || height > max_side {
248 let scale = width.max(height) as f64 / max_side as f64;
249 let opt_w = (target_w as f64 * scale).round() as u32;
250 let opt_h = (target_h as f64 * scale).round() as u32;
251 return (opt_w.max(1), opt_h.max(1));
252 }
253
254 (target_w, target_h)
255}
256
257pub struct TokenSavingsTable {
259 pub claude_before: TokenEstimate,
260 pub claude_after: TokenEstimate,
261 pub gpt4o_before: TokenEstimate,
262 pub gpt4o_after: TokenEstimate,
263 pub gpt5_before: TokenEstimate,
264 pub gpt5_after: TokenEstimate,
265 pub gemini_before: TokenEstimate,
266 pub gemini_after: TokenEstimate,
267}
268
269pub fn token_savings_table(orig_w: u32, orig_h: u32, opt_w: u32, opt_h: u32) -> TokenSavingsTable {
270 TokenSavingsTable {
271 claude_before: estimate_tokens(orig_w, orig_h, VisionModel::Claude),
272 claude_after: estimate_tokens(opt_w, opt_h, VisionModel::Claude),
273 gpt4o_before: estimate_tokens(orig_w, orig_h, VisionModel::Gpt4o),
274 gpt4o_after: estimate_tokens(opt_w, opt_h, VisionModel::Gpt4o),
275 gpt5_before: estimate_tokens(orig_w, orig_h, VisionModel::Gpt5),
276 gpt5_after: estimate_tokens(opt_w, opt_h, VisionModel::Gpt5),
277 gemini_before: estimate_tokens(orig_w, orig_h, VisionModel::Gemini15),
278 gemini_after: estimate_tokens(opt_w, opt_h, VisionModel::Gemini15),
279 }
280}
281
282impl TokenSavingsTable {
283 pub fn print(&self) {
284 println!(
285 "{:<12} {:>8} {:>8} {:>10}",
286 "Model", "Before", "After", "Saved"
287 );
288 println!("{}", "-".repeat(42));
289 self.print_row("Claude", &self.claude_before, &self.claude_after);
290 self.print_row("GPT-4o", &self.gpt4o_before, &self.gpt4o_after);
291 self.print_row("GPT-5", &self.gpt5_before, &self.gpt5_after);
292 self.print_row("Gemini", &self.gemini_before, &self.gemini_after);
293 }
294
295 fn print_row(&self, name: &str, before: &TokenEstimate, after: &TokenEstimate) {
296 let saved = before.tokens.saturating_sub(after.tokens);
297 let pct = if before.tokens > 0 {
298 saved as f64 / before.tokens as f64 * 100.0
299 } else {
300 0.0
301 };
302 println!(
303 "{:<12} {:>8} {:>8} {:>8} ({:.1}%)",
304 name, before.tokens, after.tokens, saved, pct
305 );
306 }
307}
308
309pub struct DimensionResult {
312 pub width: u32,
313 pub height: u32,
314 pub tiles_before: u32,
315 pub tiles_after: u32,
316}
317
318impl DimensionResult {
319 pub fn tokens_saved(&self) -> u32 {
320 self.tiles_before.saturating_sub(self.tiles_after)
321 }
322}
323
324#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
325pub enum ProcessMode {
326 Standard,
328 Ocr,
330 #[default]
332 Auto,
333}
334
335pub fn detect_ocr_mode(img: &DynamicImage) -> bool {
336 let rgb = img.to_rgb8();
337 let mut colorful_count = 0;
338 let mut total_count = 0;
339 for (x, y, p) in rgb.enumerate_pixels() {
341 if x % 4 == 0 && y % 4 == 0 {
342 total_count += 1;
343 let min = p[0].min(p[1]).min(p[2]);
344 let max = p[0].max(p[1]).max(p[2]);
345 if max.saturating_sub(min) > 25 {
346 colorful_count += 1;
347 }
348 }
349 }
350 let colorful_ratio = colorful_count as f64 / total_count.max(1) as f64;
351 colorful_ratio < 0.1 }
353
354pub struct SavingsReport {
355 pub tiles_before: u32,
356 pub tiles_after: u32,
357 pub tiles_saved: u32,
358 pub bytes_before: Option<u64>,
359 pub bytes_after: Option<u64>,
360}
361
362impl SavingsReport {
363 pub fn size_reduction_pct(&self) -> Option<f64> {
364 match (self.bytes_before, self.bytes_after) {
365 (Some(b), Some(a)) if b > 0 => Some((1.0 - a as f64 / b as f64) * 100.0),
366 _ => None,
367 }
368 }
369
370 pub fn token_reduction_pct(&self) -> f64 {
371 if self.tiles_before == 0 {
372 return 0.0;
373 }
374 self.tiles_saved as f64 / self.tiles_before as f64 * 100.0
375 }
376}
377
378pub struct ProcessResult {
379 pub image: DynamicImage,
380 pub width: u32,
381 pub height: u32,
382 pub report: SavingsReport,
383}
384
385impl ProcessResult {
386 pub fn tokens_saved(&self) -> u32 {
387 self.report.tiles_saved
388 }
389}
390
391pub fn process(
396 img: DynamicImage,
397 mode: ProcessMode,
398 input_bytes: u64,
399 cfg: &ProcessConfig,
400) -> ProcessResult {
401 let (orig_w, orig_h) = (img.width(), img.height());
402 let tiles_before = match cfg.target_model {
403 Some(model) => estimate_tokens(orig_w, orig_h, model).tiles,
404 None => tile_count(orig_w, cfg.tile_size) * tile_count(orig_h, cfg.tile_size),
405 };
406
407 let after_crop = if cfg.crop {
408 crop_padding(img, cfg.bg_tolerance)
409 } else {
410 img
411 };
412 let (mut opt_w, mut opt_h) = match cfg.target_model {
413 Some(model) => optimal_send_dimensions(after_crop.width(), after_crop.height(), model),
414 None => {
415 let d = calculate_optimal_dimensions_with(
416 after_crop.width(),
417 after_crop.height(),
418 cfg.tile_size,
419 );
420 (d.width, d.height)
421 }
422 };
423
424 if let Some(max_t) = cfg.max_tiles {
425 let (nw, nh) = enforce_max_tiles(opt_w, opt_h, max_t, cfg.tile_size, cfg.target_model);
426 opt_w = nw;
427 opt_h = nh;
428 }
429
430 let tiles_after = match cfg.target_model {
431 Some(model) => {
432 let est = estimate_tokens(opt_w, opt_h, model);
433 est.tiles
434 }
435 None => tile_count(opt_w, cfg.tile_size) * tile_count(opt_h, cfg.tile_size),
436 };
437 let resized = after_crop.resize_exact(opt_w, opt_h, FilterType::Lanczos3);
438
439 let actual_mode = match mode {
440 ProcessMode::Auto => {
441 if detect_ocr_mode(&after_crop) {
442 ProcessMode::Ocr
443 } else {
444 ProcessMode::Standard
445 }
446 }
447 m => m,
448 };
449
450 let final_image = match actual_mode {
451 ProcessMode::Standard | ProcessMode::Auto => resized,
452 ProcessMode::Ocr => binarize(resized),
453 };
454
455 ProcessResult {
456 width: final_image.width(),
457 height: final_image.height(),
458 image: final_image,
459 report: SavingsReport {
460 tiles_before,
461 tiles_after,
462 tiles_saved: tiles_before.saturating_sub(tiles_after),
463 bytes_before: if input_bytes > 0 {
464 Some(input_bytes)
465 } else {
466 None
467 },
468 bytes_after: None,
469 },
470 }
471}
472
473fn enforce_max_tiles(
474 mut width: u32,
475 mut height: u32,
476 max_tiles: u32,
477 default_tile_size: u32,
478 model: Option<VisionModel>,
479) -> (u32, u32) {
480 if max_tiles == 0 {
481 return (width, height);
482 }
483
484 let mut scale = 1.0;
485 let orig_w = width;
486 let orig_h = height;
487
488 loop {
489 let (snapped_w, snapped_h) = match model {
490 Some(m) => optimal_send_dimensions(width, height, m),
491 None => {
492 let d = calculate_optimal_dimensions_with(width, height, default_tile_size);
493 (d.width, d.height)
494 }
495 };
496
497 let tiles = match model {
498 Some(m) => estimate_tokens(snapped_w, snapped_h, m).tiles,
499 None => {
500 tile_count(snapped_w, default_tile_size) * tile_count(snapped_h, default_tile_size)
501 }
502 };
503
504 if tiles <= max_tiles || scale < 0.1 {
505 return (snapped_w, snapped_h);
506 }
507
508 scale *= 0.95;
509 width = (orig_w as f64 * scale) as u32;
510 height = (orig_h as f64 * scale) as u32;
511 width = width.max(1);
512 height = height.max(1);
513 }
514}
515
516pub fn calculate_optimal_dimensions(width: u32, height: u32) -> DimensionResult {
520 calculate_optimal_dimensions_with(width, height, 512)
521}
522
523pub fn calculate_optimal_dimensions_with(
525 width: u32,
526 height: u32,
527 tile_size: u32,
528) -> DimensionResult {
529 let opt_w = snap_to_tile_boundary(width, tile_size);
530 let opt_h = snap_to_tile_boundary(height, tile_size);
531
532 DimensionResult {
533 width: opt_w,
534 height: opt_h,
535 tiles_before: tile_count(width, tile_size) * tile_count(height, tile_size),
536 tiles_after: tile_count(opt_w, tile_size) * tile_count(opt_h, tile_size),
537 }
538}
539
540fn tile_count(dim: u32, tile_size: u32) -> u32 {
541 dim.div_ceil(tile_size)
542}
543
544fn snap_to_tile_boundary(dim: u32, tile_size: u32) -> u32 {
545 if dim.is_multiple_of(tile_size) {
546 return dim;
547 }
548 ((dim / tile_size) * tile_size).max(tile_size)
549}
550
551pub fn crop_padding(img: DynamicImage, bg_tolerance: u8) -> DynamicImage {
555 let rgba = img.to_rgba8();
556 let (w, h) = rgba.dimensions();
557
558 let corners = [
559 *rgba.get_pixel(0, 0),
560 *rgba.get_pixel(w - 1, 0),
561 *rgba.get_pixel(0, h - 1),
562 *rgba.get_pixel(w - 1, h - 1),
563 ];
564 let bg = corners[0]; let top = first_non_bg_row(&rgba, bg, bg_tolerance, true);
567 let bottom = first_non_bg_row(&rgba, bg, bg_tolerance, false);
568 let left = first_non_bg_col(&rgba, bg, bg_tolerance, true);
569 let right = first_non_bg_col(&rgba, bg, bg_tolerance, false);
570
571 if top >= bottom || left >= right {
572 return DynamicImage::ImageRgba8(rgba);
573 }
574
575 DynamicImage::ImageRgba8(
576 image::imageops::crop_imm(&rgba, left, top, right - left, bottom - top).to_image(),
577 )
578}
579
580fn is_bg(pixel: image::Rgba<u8>, bg: image::Rgba<u8>, tolerance: u8) -> bool {
581 pixel.0[3] < 10
582 || pixel.0[..3]
583 .iter()
584 .zip(bg.0[..3].iter())
585 .all(|(&a, &b)| a.abs_diff(b) <= tolerance)
586}
587
588fn first_non_bg_row(img: &image::RgbaImage, bg: image::Rgba<u8>, tol: u8, from_top: bool) -> u32 {
589 let (w, h) = img.dimensions();
590 let rows: Box<dyn Iterator<Item = u32>> = if from_top {
591 Box::new(0..h)
592 } else {
593 Box::new((0..h).rev())
594 };
595 for y in rows {
596 if (0..w).any(|x| !is_bg(*img.get_pixel(x, y), bg, tol)) {
597 return y;
598 }
599 }
600 0
601}
602
603fn first_non_bg_col(img: &image::RgbaImage, bg: image::Rgba<u8>, tol: u8, from_left: bool) -> u32 {
604 let (w, h) = img.dimensions();
605 let cols: Box<dyn Iterator<Item = u32>> = if from_left {
606 Box::new(0..w)
607 } else {
608 Box::new((0..w).rev())
609 };
610 for x in cols {
611 if (0..h).any(|y| !is_bg(*img.get_pixel(x, y), bg, tol)) {
612 return x;
613 }
614 }
615 0
616}
617
618pub fn binarize(img: DynamicImage) -> DynamicImage {
621 let gray = img.to_luma8();
622 let (w, h) = gray.dimensions();
623 let threshold = otsu_threshold(&gray);
624 let binary: ImageBuffer<Luma<u8>, Vec<u8>> = ImageBuffer::from_fn(w, h, |x, y| {
625 let p = gray.get_pixel(x, y).0[0];
626 Luma([if p < threshold { 0u8 } else { 255u8 }])
627 });
628 DynamicImage::ImageLuma8(binary)
629}
630
631fn otsu_threshold(img: &image::GrayImage) -> u8 {
632 let mut histogram = [0u32; 256];
633 for p in img.pixels() {
634 histogram[p.0[0] as usize] += 1;
635 }
636 let total = img.width() * img.height();
637 let (mut sum, mut sum_bg, mut weight_bg) = (0f64, 0f64, 0f64);
638 for (i, &h) in histogram.iter().enumerate() {
639 sum += i as f64 * h as f64;
640 }
641 let (mut best_thresh, mut best_var) = (0u8, 0f64);
642 for (t, &h) in histogram.iter().enumerate() {
643 weight_bg += h as f64;
644 if weight_bg == 0.0 {
645 continue;
646 }
647 let weight_fg = total as f64 - weight_bg;
648 if weight_fg == 0.0 {
649 break;
650 }
651 sum_bg += t as f64 * h as f64;
652 let mean_bg = sum_bg / weight_bg;
653 let mean_fg = (sum - sum_bg) / weight_fg;
654 let var = weight_bg * weight_fg * (mean_bg - mean_fg).powi(2);
655 if var > best_var {
656 best_var = var;
657 best_thresh = t as u8;
658 }
659 }
660 best_thresh
661}
662
663pub fn decode_base64_image(input: &str) -> Result<DynamicImage, String> {
666 let data = if let Some(c) = input.find(',') {
667 &input[c + 1..]
668 } else {
669 input
670 };
671 let bytes = B64.decode(data.trim()).map_err(|e| e.to_string())?;
672 image::load_from_memory(&bytes).map_err(|e| e.to_string())
673}
674
675pub fn encode_image_base64(img: &DynamicImage, cfg: &ProcessConfig) -> Result<String, String> {
676 let bytes = encode_to_bytes(img, cfg)?;
677 Ok(B64.encode(bytes))
678}
679
680pub fn encode_to_bytes(img: &DynamicImage, cfg: &ProcessConfig) -> Result<Vec<u8>, String> {
682 match cfg.output_format {
683 OutputFormat::Jpeg => {
684 use image::codecs::jpeg::JpegEncoder;
685 let mut buf = Cursor::new(Vec::new());
686 let rgb = img.to_rgb8();
687 JpegEncoder::new_with_quality(&mut buf, cfg.quality)
688 .encode_image(&DynamicImage::ImageRgb8(rgb))
689 .map_err(|e| e.to_string())?;
690 Ok(buf.into_inner())
691 }
692 OutputFormat::WebP => {
693 let rgb = img.to_rgb8();
694 let enc = webp::Encoder::from_rgb(rgb.as_raw(), rgb.width(), rgb.height());
695 let mem = enc.encode(cfg.quality as f32);
696 Ok(mem.to_vec())
697 }
698 }
699}
700
701pub struct OptimizeResult {
704 pub optimized_base64: String,
705 pub report: SavingsReport,
706 pub original_width: u32,
707 pub original_height: u32,
708 pub width: u32,
709 pub height: u32,
710 pub optimized_bytes: usize,
711}
712
713pub fn optimize_image(
715 input_base64: &str,
716 mode: ProcessMode,
717 cfg: &ProcessConfig,
718) -> Result<OptimizeResult, String> {
719 let img = decode_base64_image(input_base64)?;
720 let (orig_w, orig_h) = (img.width(), img.height());
721 let input_bytes = {
722 let data = if let Some(c) = input_base64.find(',') {
723 &input_base64[c + 1..]
724 } else {
725 input_base64
726 };
727 B64.decode(data.trim()).map_err(|e| e.to_string())?.len() as u64
728 };
729
730 let mut result = process(img, mode, input_bytes, cfg);
731 let bytes = encode_to_bytes(&result.image, cfg)?;
732 let encoded = B64.encode(&bytes);
733 result.report.bytes_after = Some(bytes.len() as u64);
734
735 Ok(OptimizeResult {
736 optimized_base64: encoded,
737 report: result.report,
738 original_width: orig_w,
739 original_height: orig_h,
740 width: result.width,
741 height: result.height,
742 optimized_bytes: bytes.len(),
743 })
744}
745
746#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
752#[serde(rename_all = "lowercase", tag = "op")]
753pub enum ImageOp {
754 Crop {
756 x: u32,
757 y: u32,
758 width: u32,
759 height: u32,
760 },
761 Grayscale,
763 Binarize { threshold: Option<u8> },
765 Resize { width: u32, height: u32 },
767 Contrast { amount: f32 },
769 Brightness { amount: f32 },
771}
772
773pub fn process_with_operations(mut img: DynamicImage, ops: Vec<ImageOp>) -> DynamicImage {
775 for op in ops {
776 img = match op {
777 ImageOp::Crop {
778 x,
779 y,
780 width,
781 height,
782 } => img.crop_imm(x, y, width, height),
783 ImageOp::Grayscale => DynamicImage::ImageLuma8(img.to_luma8()),
784 ImageOp::Binarize { threshold } => {
785 let gray = img.to_luma8();
786 let thr = threshold.unwrap_or(128);
787 let mut binarized = ImageBuffer::new(gray.width(), gray.height());
788 for (x, y, p) in gray.enumerate_pixels() {
789 let val = if p[0] > thr { 255 } else { 0 };
790 binarized.put_pixel(x, y, Luma([val]));
791 }
792 DynamicImage::ImageLuma8(binarized)
793 }
794 ImageOp::Resize { width, height } => {
795 img.resize_exact(width, height, FilterType::Lanczos3)
796 }
797 ImageOp::Contrast { amount } => img.adjust_contrast(amount),
798 ImageOp::Brightness { amount } => img.brighten(amount as i32),
799 };
800 }
801 img
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807
808 fn cfg() -> ProcessConfig {
809 ProcessConfig::default()
810 }
811
812 #[test]
813 fn exact_boundary_unchanged() {
814 let r = calculate_optimal_dimensions(1024, 512);
815 assert_eq!((r.width, r.height), (1024, 512));
816 assert_eq!(r.tokens_saved(), 0);
817 }
818
819 #[test]
820 fn one_pixel_over_saves_full_tile_row() {
821 let r = calculate_optimal_dimensions(1025, 1025);
822 assert_eq!((r.width, r.height), (1024, 1024));
823 assert_eq!(r.tiles_before, 9);
824 assert_eq!(r.tiles_after, 4);
825 assert_eq!(r.tokens_saved(), 5);
826 }
827
828 #[test]
829 fn small_image_never_below_one_tile() {
830 let r = calculate_optimal_dimensions(100, 200);
831 assert_eq!((r.width, r.height), (512, 512));
832 }
833
834 #[test]
835 fn mid_boundary_snaps_down() {
836 let r = calculate_optimal_dimensions(768, 512);
837 assert_eq!(r.width, 512);
838 assert_eq!(r.tiles_after, 1);
839 }
840
841 #[test]
842 fn custom_tile_size_256() {
843 let r = calculate_optimal_dimensions_with(257, 512, 256);
844 assert_eq!(r.width, 256); assert_eq!(r.tiles_before, 2 * 2); assert_eq!(r.tiles_after, 1 * 2); }
848
849 #[test]
850 fn full_pipeline_reduces_tiles() {
851 use image::{DynamicImage, Rgba, RgbaImage};
852 let mut img = RgbaImage::from_pixel(1025, 1025, Rgba([255, 255, 255, 255]));
853 for x in 400..600 {
854 for y in 400..600 {
855 img.put_pixel(x, y, Rgba([0, 0, 0, 255]));
856 }
857 }
858 let result = process(
859 DynamicImage::ImageRgba8(img),
860 ProcessMode::Standard,
861 0,
862 &cfg(),
863 );
864 assert!(result.report.tiles_after < result.report.tiles_before);
865 }
866
867 #[test]
868 fn crop_disabled_preserves_size() {
869 use image::{DynamicImage, Rgba, RgbaImage};
870 let img = RgbaImage::from_pixel(1024, 1024, Rgba([255, 255, 255, 255]));
871 let no_crop = ProcessConfig::builder().crop(false).build();
872 let result = process(
873 DynamicImage::ImageRgba8(img),
874 ProcessMode::Standard,
875 0,
876 &no_crop,
877 );
878 assert_eq!(result.width, 1024);
879 }
880
881 #[test]
882 fn crop_removes_white_border() {
883 use image::{Rgba, RgbaImage};
884 let mut img = RgbaImage::from_pixel(100, 100, Rgba([255, 255, 255, 255]));
885 for x in 45..55 {
886 for y in 45..55 {
887 img.put_pixel(x, y, Rgba([255, 0, 0, 255]));
888 }
889 }
890 let cropped = crop_padding(DynamicImage::ImageRgba8(img), 15);
891 assert!(cropped.width() < 100 && cropped.height() < 100);
892 }
893
894 #[test]
895 fn binarize_produces_only_black_white() {
896 use image::{DynamicImage, GrayImage, Luma};
897 let img = GrayImage::from_fn(64, 64, |x, _| Luma([if x < 32 { 50u8 } else { 200u8 }]));
898 let result = binarize(DynamicImage::ImageLuma8(img)).to_luma8();
899 for p in result.pixels() {
900 assert!(p.0[0] == 0 || p.0[0] == 255);
901 }
902 }
903
904 #[test]
905 fn high_bg_tolerance_crops_more() {
906 use image::{DynamicImage, Rgba, RgbaImage};
907 let mut img = RgbaImage::from_pixel(100, 100, Rgba([240, 240, 240, 255]));
911 for corner in [(0u32, 0u32), (99, 0), (0, 99), (99, 99)] {
912 img.put_pixel(corner.0, corner.1, Rgba([255, 255, 255, 255]));
913 }
914 for x in 45..55 {
915 for y in 45..55 {
916 img.put_pixel(x, y, Rgba([0, 0, 0, 255]));
917 }
918 }
919 let strict = crop_padding(DynamicImage::ImageRgba8(img.clone()), 5);
920 let loose = crop_padding(DynamicImage::ImageRgba8(img), 20);
921 assert!(loose.width() < strict.width());
922 }
923}
924#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
927pub struct OptimizationReport {
928 pub timestamp: String,
929 pub model: String,
930 pub original_tokens: u32,
931 pub optimized_tokens: u32,
932 pub original_bytes: u64,
933 pub optimized_bytes: u64,
934 pub mode: String,
935}
936
937#[derive(Debug, serde::Serialize, serde::Deserialize)]
938pub struct SqueezerStats {
939 pub total_optimizations: u64,
940 pub total_original_tokens: u64,
941 pub total_optimized_tokens: u64,
942 pub total_original_bytes: u64,
943 pub total_optimized_bytes: u64,
944 pub history: Vec<OptimizationReport>,
945}
946
947impl SqueezerStats {
948 pub fn total_token_savings(&self) -> u64 {
949 self.total_original_tokens
950 .saturating_sub(self.total_optimized_tokens)
951 }
952
953 pub fn total_byte_savings(&self) -> u64 {
954 self.total_original_bytes
955 .saturating_sub(self.total_optimized_bytes)
956 }
957
958 pub fn estimated_usd_saved(&self) -> f64 {
959 (self.total_token_savings() as f64 / 1_000_000.0) * 2.50
961 }
962}
963
964pub struct Persistence;
965
966impl Persistence {
967 fn get_db_path() -> PathBuf {
968 let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
969 path.push(".vision-squeezer");
970 let _ = std::fs::create_dir_all(&path);
971 path.push("stats.db");
972 path
973 }
974
975 pub fn init_db() -> Result<(), String> {
976 let conn = Connection::open(Self::get_db_path()).map_err(|e| e.to_string())?;
977 conn.execute(
978 "CREATE TABLE IF NOT EXISTS optimizations (
979 id INTEGER PRIMARY KEY AUTOINCREMENT,
980 timestamp TEXT NOT NULL,
981 model TEXT NOT NULL,
982 original_tokens INTEGER NOT NULL,
983 optimized_tokens INTEGER NOT NULL,
984 original_bytes INTEGER NOT NULL,
985 optimized_bytes INTEGER NOT NULL,
986 mode TEXT NOT NULL
987 )",
988 [],
989 )
990 .map_err(|e| e.to_string())?;
991 Ok(())
992 }
993
994 pub fn log_optimization(
995 model: &str,
996 orig_tokens: u32,
997 opt_tokens: u32,
998 orig_bytes: u64,
999 opt_bytes: u64,
1000 mode: &str,
1001 ) -> Result<(), String> {
1002 let conn = Connection::open(Self::get_db_path()).map_err(|e| e.to_string())?;
1003 conn.execute(
1004 "INSERT INTO optimizations (timestamp, model, original_tokens, optimized_tokens, original_bytes, optimized_bytes, mode)
1005 VALUES (?, ?, ?, ?, ?, ?, ?)",
1006 params![
1007 Utc::now().to_rfc3339(),
1008 model,
1009 orig_tokens,
1010 opt_tokens,
1011 orig_bytes as i64,
1012 opt_bytes as i64,
1013 mode,
1014 ],
1015 ).map_err(|e| e.to_string())?;
1016 Ok(())
1017 }
1018
1019 pub fn get_stats() -> Result<SqueezerStats, String> {
1020 let conn = Connection::open(Self::get_db_path()).map_err(|e| e.to_string())?;
1021
1022 let mut stmt = conn
1023 .prepare(
1024 "SELECT
1025 COUNT(*),
1026 SUM(original_tokens),
1027 SUM(optimized_tokens),
1028 SUM(original_bytes),
1029 SUM(optimized_bytes)
1030 FROM optimizations",
1031 )
1032 .map_err(|e| e.to_string())?;
1033
1034 let (count, orig_t, opt_t, orig_b, opt_b) = stmt
1035 .query_row([], |row| {
1036 Ok((
1037 row.get::<_, Option<i64>>(0)?.unwrap_or(0) as u64,
1038 row.get::<_, Option<i64>>(1)?.unwrap_or(0) as u64,
1039 row.get::<_, Option<i64>>(2)?.unwrap_or(0) as u64,
1040 row.get::<_, Option<i64>>(3)?.unwrap_or(0) as u64,
1041 row.get::<_, Option<i64>>(4)?.unwrap_or(0) as u64,
1042 ))
1043 })
1044 .map_err(|e| e.to_string())?;
1045
1046 let mut stmt = conn.prepare(
1047 "SELECT timestamp, model, original_tokens, optimized_tokens, original_bytes, optimized_bytes, mode
1048 FROM optimizations ORDER BY timestamp DESC LIMIT 50"
1049 ).map_err(|e| e.to_string())?;
1050
1051 let history = stmt
1052 .query_map([], |row| {
1053 Ok(OptimizationReport {
1054 timestamp: row.get(0)?,
1055 model: row.get(1)?,
1056 original_tokens: row.get(2)?,
1057 optimized_tokens: row.get(3)?,
1058 original_bytes: row.get::<_, i64>(4)? as u64,
1059 optimized_bytes: row.get::<_, i64>(5)? as u64,
1060 mode: row.get(6)?,
1061 })
1062 })
1063 .map_err(|e| e.to_string())?
1064 .collect::<Result<Vec<_>, _>>()
1065 .map_err(|e| e.to_string())?;
1066
1067 Ok(SqueezerStats {
1068 total_optimizations: count,
1069 total_original_tokens: orig_t,
1070 total_optimized_tokens: opt_t,
1071 total_original_bytes: orig_b,
1072 total_optimized_bytes: opt_b,
1073 history,
1074 })
1075 }
1076}