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).
287 lines
11 KiB
Rust
287 lines
11 KiB
Rust
//! 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, ¶ms(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, ¶ms(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, ¶ms(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, ¶ms(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, ¶ms(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, ¶ms(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"
|
||
);
|
||
}
|
||
}
|
||
}
|