Files
Kigi-CLI/crates/codegen/kigi-tools/src/util/image_compress.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

287 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Shared image re-encoding with PNG+JPEG format selection.
//!
//! Both the user-attachment normalizer (`kigi-shell`) and the `read_file`
//! tool image path use this to compress images under a byte-size cap while
//! respecting per-caller dimension and quality parameters.
use std::borrow::Cow;
use image::DynamicImage;
use image::codecs::jpeg::JpegEncoder;
pub use image::imageops::FilterType;
/// Parameters that control the re-encode loop.
///
/// Each call-site provides its own set of limits so the shared encoder can
/// serve callers with different size/quality trade-offs.
#[derive(Debug, Clone, Copy)]
pub struct ReEncodeParams {
/// Maximum output size in **raw bytes** (not base64).
pub max_bytes: usize,
/// Maximum dimension (width or height) on the first attempt.
pub max_side_px: u32,
/// Maximum total output pixel count (width × height) on the first
/// attempt; `u64::MAX` disables the area cap.
pub max_pixels: u64,
/// Floor dimension — the loop gives up when `max_side` falls to or below
/// this value without producing output that fits.
pub min_side_px: u32,
/// JPEG quality steps to try at each dimension, in descending order.
pub quality_steps: &'static [u8],
/// Resize filter (e.g. `CatmullRom`, `Lanczos3`).
pub filter: FilterType,
}
impl ReEncodeParams {
/// True when either side exceeds `max_side_px` or the total pixel count
/// exceeds `max_pixels` — shared by re-encode triggers and passthrough
/// gates so the rule cannot drift between them.
pub fn exceeds_dimension_caps(&self, w: u32, h: u32) -> bool {
w > self.max_side_px
|| h > self.max_side_px
|| u64::from(w) * u64::from(h) > self.max_pixels
}
}
/// Why `re_encode_under_limit` could not produce a compliant output.
#[derive(Debug, thiserror::Error)]
pub enum ReEncodeError {
/// Exhausted all quality × dimension steps without fitting under the cap.
#[error(
"re-encode could not fit under {max_bytes} bytes after PNG+JPEG attempts (last side {last_side}px)"
)]
CouldNotFit { max_bytes: usize, last_side: u32 },
}
/// Try PNG and JPEG encodings at descending dimensions, returning whichever
/// is smallest and fits under `params.max_bytes`.
///
/// On success returns `(bytes, width, height, mime_type)`.
pub fn re_encode_under_limit(
decoded: &DynamicImage,
params: &ReEncodeParams,
) -> Result<(Vec<u8>, u32, u32, &'static str), ReEncodeError> {
// Never upscale: a small-but-heavy image is re-encoded at its own
// resolution, not enlarged to `max_side_px`. `image::resize` scales *up* to
// fill the target box, so starting at `max_side_px` would enlarge anything
// smaller — adding no detail and wasting request bytes / cache headroom.
let original_max_side = decoded.width().max(decoded.height());
let mut max_side = params.max_side_px.min(original_max_side);
let original_pixels = u64::from(decoded.width()) * u64::from(decoded.height());
if original_pixels > params.max_pixels {
max_side = max_side.min(area_capped_side(
original_max_side,
decoded.width().min(decoded.height()),
params.max_pixels,
));
}
loop {
// Only resample when actually downscaling; resizing to the current size
// would just soften the image for no reason. `resize(w, h)` preserves
// aspect ratio (fits inside w×h, not stretch-to-square).
let scaled: Cow<'_, DynamicImage> = if max_side < original_max_side {
Cow::Owned(decoded.resize(max_side, max_side, params.filter))
} else {
Cow::Borrowed(decoded)
};
let img: &DynamicImage = &scaled;
let (w, h) = (img.width(), img.height());
// --- PNG candidate ---------------------------------------------------
let png_candidate = {
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.ok()
.filter(|_| buf.len() <= params.max_bytes)
.map(|_| buf)
};
// --- JPEG candidate (best quality that fits) -------------------------
let jpeg_candidate = params.quality_steps.iter().find_map(|&quality| {
let mut buf = Vec::new();
let mut enc = JpegEncoder::new_with_quality(&mut buf, quality);
enc.encode_image(img).ok()?;
(buf.len() <= params.max_bytes).then_some(buf)
});
// --- Pick the smaller candidate --------------------------------------
match (png_candidate, jpeg_candidate) {
(Some(png), Some(jpeg)) => {
if png.len() <= jpeg.len() {
return Ok((png, w, h, "image/png"));
} else {
return Ok((jpeg, w, h, "image/jpeg"));
}
}
(Some(png), None) => return Ok((png, w, h, "image/png")),
(None, Some(jpeg)) => return Ok((jpeg, w, h, "image/jpeg")),
(None, None) => { /* fall through to smaller dimensions */ }
}
if max_side <= params.min_side_px {
return Err(ReEncodeError::CouldNotFit {
max_bytes: params.max_bytes,
last_side: max_side,
});
}
max_side = max_side * 3 / 4;
}
}
/// Largest target long side whose resize output area stays within `max_pixels`.
fn area_capped_side(long: u32, short: u32, max_pixels: u64) -> u32 {
let scale = (max_pixels as f64 / (u64::from(long) * u64::from(short)) as f64).sqrt();
let mut side = ((f64::from(long) * scale).floor() as u32).clamp(1, long);
// Nearest-rounding of the short side can overshoot the budget by ~side/2
// pixels, so step down until the predicted output fits: a decrement removes
// ~2*area/side pixels, giving ~2 iterations for ordinary aspect ratios;
// only degenerate strips whose short side pins at the 1px floor walk
// O(side), bounded by the callers' decode-pixel limits.
while side > 1 && predicted_resize_area(long, short, side) > max_pixels {
side -= 1;
}
side
}
/// Output area `image::resize` produces for a `side`×`side` bounding box,
/// mirroring `resize_dimensions` (image-0.25.9, `src/math/utils.rs`)
/// expression-for-expression; the `area_cap_exact_fit_across_aspect_ratios`
/// sweep pins the equivalence through the real resize, so a crate bump that
/// changes the rounding shows up as a test failure pointing here.
fn predicted_resize_area(long: u32, short: u32, side: u32) -> u64 {
let ratio = f64::from(side) / f64::from(long);
let scaled_long = (f64::from(long) * ratio).round().max(1.0) as u64;
let scaled_short = (f64::from(short) * ratio).round().max(1.0) as u64;
scaled_long * scaled_short
}
#[cfg(test)]
mod tests {
use image::{DynamicImage, Rgb, RgbImage};
use super::*;
/// Deterministic high-entropy image so PNG/JPEG can't trivially shrink it.
fn noise(w: u32, h: u32) -> DynamicImage {
let mut img = RgbImage::new(w, h);
let mut s: u32 = 0x1234_5678;
for p in img.pixels_mut() {
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
*p = Rgb([(s >> 16) as u8, (s >> 8) as u8, s as u8]);
}
DynamicImage::ImageRgb8(img)
}
fn params(max_bytes: usize, max_side_px: u32, max_pixels: u64) -> ReEncodeParams {
ReEncodeParams {
max_bytes,
max_side_px,
max_pixels,
min_side_px: 256,
quality_steps: &[88, 72, 56, 40, 24],
filter: FilterType::CatmullRom,
}
}
#[test]
fn does_not_upscale_images_smaller_than_the_side_cap() {
// 1280x960 is already under the test's 1568px side cap. Re-encoding
// must NOT enlarge it — output dimensions must never exceed the input.
// (Regression: the resize previously scaled small images up to
// `max_side_px`.)
let img = noise(1280, 960);
let (_bytes, w, h, _mime) =
re_encode_under_limit(&img, &params(5_000_000, 1568, u64::MAX)).unwrap();
assert!(
w <= 1280 && h <= 960,
"must not upscale a 1280x960 image, got {w}x{h}"
);
}
#[test]
fn downscales_images_larger_than_the_side_cap() {
// 2000x1500 exceeds the test's 1568px side cap and must fit inside it.
let img = noise(2000, 1500);
let (_bytes, w, h, _mime) =
re_encode_under_limit(&img, &params(5_000_000, 1568, u64::MAX)).unwrap();
assert!(
w <= 1568 && h <= 1568,
"should downscale to <=1568, got {w}x{h}"
);
assert_eq!(w, 1568, "longest side should hit the cap");
}
#[test]
fn shrinks_dimensions_only_when_bytes_force_it() {
// A small image that can't fit the byte cap at native size is
// downscaled below its own dimensions — still never above them.
let img = noise(1280, 960);
let (bytes, w, h, _mime) =
re_encode_under_limit(&img, &params(120_000, 1568, u64::MAX)).unwrap();
assert!(bytes.len() <= 120_000);
assert!(w <= 1280 && h <= 960, "must not upscale, got {w}x{h}");
}
#[test]
fn area_cap_bounds_total_pixels_for_wide_images() {
// 3438x1830 = 6.29 Mpx: the side cap is loose, so only the area budget
// binds; expected long side = floor(3438 * sqrt(2_408_448 / 6_291_540)).
let img = noise(3438, 1830);
let (_bytes, w, h, _mime) =
re_encode_under_limit(&img, &params(50_000_000, 10_000, 2_408_448)).unwrap();
let area = u64::from(w) * u64::from(h);
assert!(area <= 2_408_448, "area {area} over budget ({w}x{h})");
assert!(
area >= 2_300_000,
"should use most of the budget, got {area} ({w}x{h})"
);
assert_eq!(w, 2127, "long side ~2127 for a 3438x1830 source");
let r_in = 3438.0 / 1830.0;
let r_out = w as f64 / h as f64;
assert!(
(r_in - r_out).abs() < 0.01,
"aspect ratio {r_in} -> {r_out} ({w}x{h})"
);
}
#[test]
fn image_under_area_cap_is_not_resized() {
// 1500x1500 = 2.25 Mpx is under the 2_408_448 budget; no resample.
let img = noise(1500, 1500);
let (_bytes, w, h, _mime) =
re_encode_under_limit(&img, &params(50_000_000, 10_000, 2_408_448)).unwrap();
assert_eq!((w, h), (1500, 1500), "must not up- or downscale");
}
#[test]
fn area_cap_exact_fit_across_aspect_ratios() {
// Short-side rounding must never push the output area over the cap;
// (1600, 400, 300_000) rounds 273.5 up and exercises the decrement.
for &(sw, sh, cap) in &[
(1300u32, 900u32, 500_000u64),
(1200, 1199, 640_000),
(1600, 400, 300_000),
(900, 1300, 777_777),
(1000, 1000, 123_456),
(2600, 1800, 2_408_448),
] {
let img = noise(sw, sh);
let (_bytes, w, h, _mime) =
re_encode_under_limit(&img, &params(50_000_000, 10_000, cap)).unwrap();
let area = u64::from(w) * u64::from(h);
assert!(area <= cap, "{sw}x{sh} cap {cap}: got {w}x{h} = {area}");
assert_eq!(
w.max(h),
area_capped_side(sw.max(sh), sw.min(sh), cap),
"{sw}x{sh} cap {cap}: long side must match the predicted fit"
);
}
}
}