mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
* feat(kosong): classify HTTP 413 request-body-too-large as a dedicated error type * feat(agent-core): lower default image downscale cap to 2000px and make it configurable * feat(agent-core): strip media to text markers and retry when the compaction request is too large * feat(agent-core): cap model-initiated image reads with a configurable byte budget * feat(agent-core): resend with degraded media when the provider rejects the request body as too large * test(agent-core): add explicit timeouts to encode-heavy image budget tests * feat: add WebP decoding support with wasm integration - Introduced a new WebP decoding module using @jsquash/webp's wasm decoder. - Implemented functions to decode WebP images and check for animated WebP formats. - Updated image compression tests to include scenarios for WebP handling, including encoding and decoding. - Enhanced error handling for API request size limits to accommodate various error messages. - Updated pnpm lockfile to include new dependencies for WebP encoding and decoding. * chore(changeset): consolidate this PR's entries into one * fix(nix): update pnpmDeps hash for merged lockfile * feat(agent-core): refuse HEIC/HEIF reads with platform-matched conversion guidance
1546 lines
64 KiB
TypeScript
1546 lines
64 KiB
TypeScript
/**
|
||
* image-compress — downsample/re-encode oversized images for the model.
|
||
*
|
||
* Tests pin:
|
||
* - fast path: an image within both budgets passes through untouched
|
||
* (same byte reference, no re-encode)
|
||
* - dimension cap: an oversized image is scaled so its longest edge is
|
||
* exactly MAX_IMAGE_EDGE_PX, preserving aspect ratio
|
||
* - byte budget: an over-budget image walks the JPEG quality ladder and
|
||
* comes back as JPEG, strictly smaller than the input
|
||
* - alpha: a translucent PNG stays PNG when the budget allows, and only
|
||
* drops to JPEG as a last resort to meet a tiny budget
|
||
* - fallback: corrupt/empty bytes and non-recodable formats (GIF,
|
||
* animated WebP) return the original unchanged — never throws
|
||
* - webp: still WebP decodes through the bundled wasm codec and re-encodes
|
||
* on the lossless-first ladder; animated WebP passes through whole
|
||
* - invariant: `changed` implies the result is strictly smaller
|
||
* - base64 wrapper round-trips
|
||
* - performance: the fast path is codec-free; a large image compresses
|
||
* within a generous time bound
|
||
* - metadata: results always carry the original pixel dimensions
|
||
* - crop: cropImageForModel cuts a region at native resolution, clamps
|
||
* overflow, refuses out-of-bounds/undecodable input explicitly, and
|
||
* honors skipResize with a hard byte-budget failure
|
||
* - caption: buildImageCompressionCaption renders a consistent
|
||
* `<system>` note (dims, sizes, readback path)
|
||
* - annotate: compressImageContentParts can collect that caption for
|
||
* each compressed image and persist the original via a callback
|
||
* - quality guards: a 1px checkerboard downscales to flat gray (no
|
||
* spectral aliasing) at integer and fractional ratios, with jimp's
|
||
* point-sampled BILINEAR mode pinned as the aliasing counter-example;
|
||
* fully transparent pixels never bleed color into opaque edges; mean
|
||
* brightness survives the downscale; recompressing a compressed
|
||
* result is a no-op; extreme aspect ratios never collapse to zero
|
||
*/
|
||
|
||
import { createRequire } from 'node:module';
|
||
|
||
import { Jimp, ResizeStrategy } from 'jimp';
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
// eslint-disable-next-line import/no-unresolved
|
||
import {
|
||
buildImageCompressionCaption,
|
||
compressBase64ForModel,
|
||
compressImageContentParts,
|
||
compressImageForModel,
|
||
cropImageForModel,
|
||
extractImageCompressionCaptions,
|
||
IMAGE_BYTE_BUDGET,
|
||
MAX_IMAGE_EDGE_ENV,
|
||
MAX_IMAGE_EDGE_PX,
|
||
READ_IMAGE_BYTE_BUDGET,
|
||
READ_IMAGE_BYTE_BUDGET_ENV,
|
||
resolveMaxImageEdgePx,
|
||
resolveReadImageByteBudget,
|
||
setConfiguredMaxImageEdgePx,
|
||
setConfiguredReadImageByteBudget,
|
||
} from '../../src/tools/support/image-compress';
|
||
// eslint-disable-next-line import/no-unresolved
|
||
import { sniffImageDimensions } from '../../src/tools/support/file-type';
|
||
// eslint-disable-next-line import/no-unresolved
|
||
import type { TelemetryClient, TelemetryProperties } from '../../src/telemetry';
|
||
|
||
// ── fixtures ─────────────────────────────────────────────────────────
|
||
|
||
async function solidPng(width: number, height: number, color = 0x3366ccff): Promise<Uint8Array> {
|
||
const image = new Jimp({ width, height, color });
|
||
return new Uint8Array(await image.getBuffer('image/png'));
|
||
}
|
||
|
||
async function solidJpeg(width: number, height: number, color = 0x3366ccff): Promise<Uint8Array> {
|
||
const image = new Jimp({ width, height, color });
|
||
return new Uint8Array(await image.getBuffer('image/jpeg', { quality: 90 }));
|
||
}
|
||
|
||
async function translucentPng(width: number, height: number): Promise<Uint8Array> {
|
||
// Alpha 0x80 on every pixel → hasAlpha() is true.
|
||
const image = new Jimp({ width, height, color: 0x33_66_cc_80 });
|
||
return new Uint8Array(await image.getBuffer('image/png'));
|
||
}
|
||
|
||
/** High-entropy image whose PNG barely compresses — used to force the ladder. */
|
||
async function noisePng(width: number, height: number, alpha = false): Promise<Uint8Array> {
|
||
const image = new Jimp({ width, height, color: 0x000000ff });
|
||
const data = image.bitmap.data;
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
// Deterministic pseudo-random bytes (no Math.random for stable fixtures).
|
||
// Distinct multipliers per channel keep entropy high so PNG barely shrinks.
|
||
data[i] = (i * 2_654_435_761) & 0xff;
|
||
data[i + 1] = (i * 40_503) & 0xff;
|
||
data[i + 2] = (i * 12_289) & 0xff;
|
||
data[i + 3] = alpha ? (i * 7 + 17) & 0xff : 0xff;
|
||
}
|
||
return new Uint8Array(await image.getBuffer('image/png'));
|
||
}
|
||
|
||
/**
|
||
* Statistically random (deterministic xorshift) noise. Unlike noisePng's
|
||
* periodic pattern — whose post-resize deflate size is unpredictable — this
|
||
* stays roughly proportionally incompressible after a resize smooths it,
|
||
* so byte sizes can be compared across scales.
|
||
*/
|
||
async function randomNoisePng(width: number, height: number): Promise<Uint8Array> {
|
||
const image = new Jimp({ width, height, color: 0x000000ff });
|
||
fillXorshiftNoise(image.bitmap.data);
|
||
return new Uint8Array(await image.getBuffer('image/png'));
|
||
}
|
||
|
||
/** JPEG twin of {@link randomNoisePng}, for exercising the JPEG source path. */
|
||
async function randomNoiseJpeg(width: number, height: number): Promise<Uint8Array> {
|
||
const image = new Jimp({ width, height, color: 0x000000ff });
|
||
fillXorshiftNoise(image.bitmap.data);
|
||
return new Uint8Array(await image.getBuffer('image/jpeg', { quality: 90 }));
|
||
}
|
||
|
||
function fillXorshiftNoise(data: Buffer | Uint8Array): void {
|
||
let state = 0x9e3779b9;
|
||
const next = (): number => {
|
||
state ^= (state << 13) >>> 0;
|
||
state ^= state >>> 17;
|
||
state ^= (state << 5) >>> 0;
|
||
state >>>= 0;
|
||
return state & 0xff;
|
||
};
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
data[i] = next();
|
||
data[i + 1] = next();
|
||
data[i + 2] = next();
|
||
data[i + 3] = 0xff;
|
||
}
|
||
}
|
||
|
||
async function decodeAlpha(bytes: Uint8Array): Promise<boolean> {
|
||
const image = await Jimp.fromBuffer(Buffer.from(bytes));
|
||
return image.hasAlpha();
|
||
}
|
||
|
||
/**
|
||
* Insert a minimal EXIF APP1 segment carrying only an Orientation tag right
|
||
* after the JPEG SOI marker (jimp itself never writes EXIF).
|
||
*/
|
||
function withExifOrientation(jpeg: Uint8Array, orientation: number): Uint8Array {
|
||
// TIFF body, little-endian: 8-byte header + IFD0 with a single entry.
|
||
const tiff = Buffer.alloc(26);
|
||
tiff.write('II', 0, 'latin1');
|
||
tiff.writeUInt16LE(42, 2);
|
||
tiff.writeUInt32LE(8, 4); // offset of IFD0
|
||
tiff.writeUInt16LE(1, 8); // one directory entry
|
||
tiff.writeUInt16LE(0x0112, 10); // tag: Orientation
|
||
tiff.writeUInt16LE(3, 12); // type: SHORT
|
||
tiff.writeUInt32LE(1, 14); // count
|
||
tiff.writeUInt16LE(orientation, 18); // value, left-aligned in the 4-byte field
|
||
tiff.writeUInt32LE(0, 22); // no next IFD
|
||
const exifBody = Buffer.concat([Buffer.from('Exif\0\0', 'latin1'), tiff]);
|
||
const app1Header = Buffer.alloc(4);
|
||
app1Header.writeUInt16BE(0xff_e1, 0);
|
||
app1Header.writeUInt16BE(exifBody.length + 2, 2);
|
||
return new Uint8Array(
|
||
Buffer.concat([
|
||
Buffer.from(jpeg.subarray(0, 2)), // SOI
|
||
app1Header,
|
||
exifBody,
|
||
Buffer.from(jpeg.subarray(2)),
|
||
]),
|
||
);
|
||
}
|
||
|
||
// ── fast path ────────────────────────────────────────────────────────
|
||
|
||
describe('compressImageForModel — fast path', () => {
|
||
it('passes a within-budget image through untouched (same reference)', async () => {
|
||
const png = await solidPng(64, 64);
|
||
const result = await compressImageForModel(png, 'image/png');
|
||
expect(result.changed).toBe(false);
|
||
expect(result.data).toBe(png); // identity: no copy, no re-encode
|
||
expect(result.mimeType).toBe('image/png');
|
||
expect(result.width).toBe(64);
|
||
expect(result.height).toBe(64);
|
||
});
|
||
|
||
it('treats image/jpg as image/jpeg', async () => {
|
||
const jpeg = await solidJpeg(32, 32);
|
||
const result = await compressImageForModel(jpeg, 'image/jpg');
|
||
expect(result.changed).toBe(false);
|
||
expect(result.data).toBe(jpeg);
|
||
});
|
||
});
|
||
|
||
// ── dimension cap ────────────────────────────────────────────────────
|
||
|
||
describe('compressImageForModel — dimension cap', () => {
|
||
it('scales the longest edge down to MAX_IMAGE_EDGE_PX, preserving aspect', async () => {
|
||
const png = await solidPng(4500, 2250);
|
||
const result = await compressImageForModel(png, 'image/png');
|
||
expect(result.changed).toBe(true);
|
||
expect(Math.max(result.width, result.height)).toBe(MAX_IMAGE_EDGE_PX);
|
||
// 4500x2250 → 2000x1000 (aspect 2:1 preserved).
|
||
expect(result.width).toBe(2000);
|
||
expect(result.height).toBe(1000);
|
||
const dims = sniffImageDimensions(result.data);
|
||
expect(dims).toEqual({ width: 2000, height: 1000 });
|
||
});
|
||
|
||
it('respects a custom maxEdge', async () => {
|
||
const png = await solidPng(1600, 800);
|
||
const result = await compressImageForModel(png, 'image/png', { maxEdge: 800 });
|
||
expect(result.changed).toBe(true);
|
||
expect(result.width).toBe(800);
|
||
expect(result.height).toBe(400);
|
||
});
|
||
|
||
it('keeps a downscaled opaque PNG lossless (no needless JPEG conversion)', async () => {
|
||
// A screenshot-like opaque PNG that only needs downscaling must stay PNG so
|
||
// sharp text is not degraded by JPEG artifacts.
|
||
const png = await solidPng(4500, 2250);
|
||
const result = await compressImageForModel(png, 'image/png');
|
||
expect(result.changed).toBe(true);
|
||
expect(result.mimeType).toBe('image/png');
|
||
expect(Math.max(result.width, result.height)).toBe(MAX_IMAGE_EDGE_PX);
|
||
});
|
||
});
|
||
|
||
// ── byte budget ──────────────────────────────────────────────────────
|
||
|
||
describe('compressImageForModel — byte budget', () => {
|
||
it('walks the JPEG ladder for an over-budget non-alpha image', async () => {
|
||
const png = await noisePng(900, 900);
|
||
const result = await compressImageForModel(png, 'image/png', { byteBudget: 8 * 1024 });
|
||
expect(result.changed).toBe(true);
|
||
expect(result.mimeType).toBe('image/jpeg');
|
||
expect(result.finalByteLength).toBeLessThan(result.originalByteLength);
|
||
});
|
||
|
||
it('keeps a translucent PNG as PNG when the budget allows', async () => {
|
||
const png = await translucentPng(3600, 1800);
|
||
const result = await compressImageForModel(png, 'image/png');
|
||
expect(result.changed).toBe(true);
|
||
expect(result.mimeType).toBe('image/png');
|
||
expect(Math.max(result.width, result.height)).toBe(MAX_IMAGE_EDGE_PX);
|
||
expect(await decodeAlpha(result.data)).toBe(true);
|
||
});
|
||
|
||
it('drops alpha to JPEG only as a last resort under a tiny budget', async () => {
|
||
const png = await noisePng(800, 800, /* alpha */ true);
|
||
const result = await compressImageForModel(png, 'image/png', { byteBudget: 4 * 1024 });
|
||
expect(result.changed).toBe(true);
|
||
expect(result.mimeType).toBe('image/jpeg');
|
||
expect(result.finalByteLength).toBeLessThan(result.originalByteLength);
|
||
});
|
||
|
||
it('steps down through the 2000px edge before the 1000px fallback', async () => {
|
||
// Fallback-ladder guard, pinned with an explicit 3000px ceiling (the
|
||
// built-in default is 2000px, where the first fallback edge is a no-op):
|
||
// a PNG whose fitted encode is over budget but whose 2000px encode fits
|
||
// must come back at 2000px, not skip straight to 1000px.
|
||
// The budget is anchored to the actual 2000px encode size (probed with
|
||
// an unlimited budget) so the test does not depend on exact deflate
|
||
// output sizes.
|
||
const png = await randomNoisePng(2400, 600);
|
||
const probe = await compressImageForModel(png, 'image/png', {
|
||
maxEdge: 2000,
|
||
byteBudget: Number.MAX_SAFE_INTEGER,
|
||
});
|
||
expect(probe.changed).toBe(true);
|
||
expect(probe.mimeType).toBe('image/png');
|
||
expect(Math.max(probe.width, probe.height)).toBe(2000);
|
||
// Sanity: the anchor budget must sit below the input size, or the run
|
||
// below would pass through on the fast path instead of re-encoding.
|
||
expect(probe.finalByteLength + 1024).toBeLessThan(png.length);
|
||
|
||
const result = await compressImageForModel(png, 'image/png', {
|
||
maxEdge: 3000,
|
||
byteBudget: probe.finalByteLength + 1024,
|
||
});
|
||
expect(result.changed).toBe(true);
|
||
expect(result.mimeType).toBe('image/png');
|
||
expect(Math.max(result.width, result.height)).toBe(2000);
|
||
});
|
||
|
||
it(
|
||
're-runs the JPEG quality ladder at fallback sizes instead of jumping to q20',
|
||
async () => {
|
||
// A JPEG whose quality ladder fails at every size above 1000px, with the
|
||
// budget tuned so that at 1000px a mid-quality (q60) encode fits. The
|
||
// fallback must walk the ladder again and return that q60 encode — not
|
||
// collapse straight to q20 and needlessly destroy detail.
|
||
// The probe replays the implementation's exact resize chain
|
||
// (2400 → 2000 → 1000): box-resizing twice does not yield the same
|
||
// bitmap as resizing once, and JPEG encoding is deterministic, so the
|
||
// probed q60 size matches the implementation's encode byte-for-byte.
|
||
// The width must exceed 2000px to exercise the full fallback chain; the
|
||
// height is kept small so the ~11 JPEG encodes stay fast on slow CI.
|
||
const jpeg = await randomNoiseJpeg(2400, 300);
|
||
const probe = await Jimp.fromBuffer(Buffer.from(jpeg));
|
||
probe.resize({ w: 2000, h: 250 });
|
||
probe.resize({ w: 1000, h: 125 });
|
||
const q60Size = (await probe.getBuffer('image/jpeg', { quality: 60 })).length;
|
||
const q20Size = (await probe.getBuffer('image/jpeg', { quality: 20 })).length;
|
||
expect(q60Size).toBeGreaterThan(q20Size); // sanity: the anchor separates the rungs
|
||
|
||
const result = await compressImageForModel(jpeg, 'image/jpeg', {
|
||
byteBudget: q60Size + 256,
|
||
});
|
||
expect(result.changed).toBe(true);
|
||
expect(result.mimeType).toBe('image/jpeg');
|
||
expect(Math.max(result.width, result.height)).toBe(1000);
|
||
// The highest quality that fits the budget at 1000px is q60.
|
||
expect(result.finalByteLength).toBe(q60Size);
|
||
},
|
||
15_000,
|
||
);
|
||
});
|
||
|
||
// ── webp fixtures (encoder wasm loaded manually from node_modules) ──
|
||
|
||
/**
|
||
* Encode RGBA pixels to WebP using the encoder wasm from node_modules —
|
||
* test-fixture only; production never encodes WebP. The emscripten glue
|
||
* cannot auto-locate its wasm under Node (it tries fetch on a file URL), so
|
||
* the module is compiled and injected manually, mirroring how production
|
||
* initializes the decoder from the bundled base64.
|
||
*/
|
||
async function encodeWebp(
|
||
image: { bitmap: { data: Buffer | Uint8Array; width: number; height: number } },
|
||
quality = 90,
|
||
): Promise<Uint8Array> {
|
||
const requireLocal = createRequire(import.meta.url);
|
||
const encMod = (await import(
|
||
requireLocal.resolve('@jsquash/webp/encode.js')
|
||
)) as typeof import('@jsquash/webp/encode.js');
|
||
const { readFileSync } = await import('node:fs');
|
||
// The repo tsconfig has no DOM lib, so the global WebAssembly name is
|
||
// reached structurally (same approach as the production decoder).
|
||
const wasmNamespace = (
|
||
globalThis as unknown as { WebAssembly: { compile(bytes: Uint8Array): Promise<object> } }
|
||
).WebAssembly;
|
||
const wasm = await wasmNamespace.compile(
|
||
readFileSync(requireLocal.resolve('@jsquash/webp/codec/enc/webp_enc.wasm')),
|
||
);
|
||
await encMod.init(wasm as never);
|
||
const { bitmap } = image;
|
||
const encoded = await encMod.default(
|
||
{
|
||
data: new Uint8ClampedArray(
|
||
bitmap.data.buffer,
|
||
bitmap.data.byteOffset,
|
||
bitmap.data.byteLength,
|
||
),
|
||
width: bitmap.width,
|
||
height: bitmap.height,
|
||
} as never,
|
||
{ quality },
|
||
);
|
||
return new Uint8Array(encoded);
|
||
}
|
||
|
||
/** Minimal VP8X container header with the ANIM flag set. */
|
||
function animatedWebpHeader(): Uint8Array {
|
||
const bytes = new Uint8Array(30);
|
||
const ascii = (s: string, at: number) => {
|
||
for (let i = 0; i < s.length; i++) bytes[at + i] = s.charCodeAt(i);
|
||
};
|
||
ascii('RIFF', 0);
|
||
new DataView(bytes.buffer).setUint32(4, 22, true);
|
||
ascii('WEBP', 8);
|
||
ascii('VP8X', 12);
|
||
new DataView(bytes.buffer).setUint32(16, 10, true);
|
||
bytes[20] = 0x02; // ANIM flag
|
||
return bytes;
|
||
}
|
||
|
||
describe('compressImageForModel — webp', () => {
|
||
it(
|
||
'downscales an oversized WebP to the edge cap',
|
||
async () => {
|
||
const source = new Jimp({ width: 2600, height: 1300, color: 0x3366ccff });
|
||
const webp = await encodeWebp(source);
|
||
const result = await compressImageForModel(webp, 'image/webp');
|
||
expect(result.changed).toBe(true);
|
||
expect(Math.max(result.width, result.height)).toBe(2000);
|
||
expect(result.originalWidth).toBe(2600);
|
||
expect(result.originalHeight).toBe(1300);
|
||
expect(sniffImageDimensions(result.data)).toEqual({ width: 2000, height: 1000 });
|
||
},
|
||
15_000,
|
||
);
|
||
|
||
it(
|
||
're-encodes an over-budget WebP within the byte budget',
|
||
async () => {
|
||
const budget = 128 * 1024;
|
||
const noisy = new Jimp({ width: 1200, height: 1200, color: 0x000000ff });
|
||
fillXorshiftNoise(noisy.bitmap.data);
|
||
const webp = await encodeWebp(noisy, 100);
|
||
expect(webp.length).toBeGreaterThan(budget);
|
||
const result = await compressImageForModel(webp, 'image/webp', { byteBudget: budget });
|
||
expect(result.changed).toBe(true);
|
||
expect(result.finalByteLength).toBeLessThanOrEqual(budget);
|
||
},
|
||
15_000,
|
||
);
|
||
|
||
it(
|
||
'keeps alpha when re-encoding a translucent WebP',
|
||
async () => {
|
||
const translucent = new Jimp({ width: 2600, height: 1300, color: 0x33_66_cc_80 });
|
||
const webp = await encodeWebp(translucent);
|
||
const result = await compressImageForModel(webp, 'image/webp');
|
||
expect(result.changed).toBe(true);
|
||
expect(result.mimeType).toBe('image/png');
|
||
expect(await decodeAlpha(result.data)).toBe(true);
|
||
},
|
||
15_000,
|
||
);
|
||
|
||
it('passes an animated WebP through to preserve animation', async () => {
|
||
const animated = animatedWebpHeader();
|
||
const result = await compressImageForModel(animated, 'image/webp');
|
||
expect(result.changed).toBe(false);
|
||
expect(result.data).toBe(animated);
|
||
});
|
||
|
||
it(
|
||
'crops a region out of a WebP',
|
||
async () => {
|
||
const source = new Jimp({ width: 800, height: 400, color: 0x3366ccff });
|
||
const webp = await encodeWebp(source);
|
||
const result = await cropImageForModel(webp, 'image/webp', {
|
||
x: 10,
|
||
y: 20,
|
||
width: 300,
|
||
height: 200,
|
||
});
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
expect(result.width).toBe(300);
|
||
expect(result.height).toBe(200);
|
||
expect(result.originalWidth).toBe(800);
|
||
expect(result.originalHeight).toBe(400);
|
||
},
|
||
15_000,
|
||
);
|
||
});
|
||
|
||
// ── small byte budgets (per-image read budget scale) ────────────────
|
||
|
||
// These walk many encode rungs over noise fixtures, which is slow on CI
|
||
// runners — each carries an explicit timeout like the quality-ladder test
|
||
// above.
|
||
describe('compressImageForModel — small byte budgets', () => {
|
||
it(
|
||
'converges under a 128KB budget for high-entropy PNG content',
|
||
async () => {
|
||
// Statistically random noise is the entropy upper bound (photos, dense
|
||
// charts): the old [2000, 1000] fallback floor left q20@1000px at ~200KB,
|
||
// over a read-scale budget. The extended ladder must land within it.
|
||
const budget = 128 * 1024;
|
||
const png = await randomNoisePng(1200, 1200);
|
||
expect(png.length).toBeGreaterThan(budget);
|
||
const result = await compressImageForModel(png, 'image/png', { byteBudget: budget });
|
||
expect(result.changed).toBe(true);
|
||
expect(result.finalByteLength).toBeLessThanOrEqual(budget);
|
||
expect(sniffImageDimensions(result.data)).not.toBeNull();
|
||
},
|
||
15_000,
|
||
);
|
||
|
||
it(
|
||
'converges under a 128KB budget for a JPEG source',
|
||
async () => {
|
||
const budget = 128 * 1024;
|
||
const jpeg = await randomNoiseJpeg(1200, 1200);
|
||
expect(jpeg.length).toBeGreaterThan(budget);
|
||
const result = await compressImageForModel(jpeg, 'image/jpeg', { byteBudget: budget });
|
||
expect(result.changed).toBe(true);
|
||
expect(result.mimeType).toBe('image/jpeg');
|
||
expect(result.finalByteLength).toBeLessThanOrEqual(budget);
|
||
},
|
||
15_000,
|
||
);
|
||
|
||
it(
|
||
'shrinks pixels instead of passing through an already-optimized JPEG over budget',
|
||
async () => {
|
||
// A JPEG already at the encoder's quality floor for its size: re-encoding
|
||
// at the same size cannot shrink it, so without sub-size fallbacks the
|
||
// "unhelpful" guard used to return the original — silently over budget.
|
||
const image = new Jimp({ width: 900, height: 900, color: 0x000000ff });
|
||
fillXorshiftNoise(image.bitmap.data);
|
||
const optimized = new Uint8Array(await image.getBuffer('image/jpeg', { quality: 20 }));
|
||
const budget = optimized.length - 10 * 1024;
|
||
expect(budget).toBeGreaterThan(0);
|
||
|
||
const result = await compressImageForModel(optimized, 'image/jpeg', { byteBudget: budget });
|
||
expect(result.changed).toBe(true);
|
||
expect(result.finalByteLength).toBeLessThanOrEqual(budget);
|
||
expect(Math.max(result.width, result.height)).toBeLessThan(900);
|
||
},
|
||
15_000,
|
||
);
|
||
});
|
||
|
||
// ── fallback / robustness ────────────────────────────────────────────
|
||
|
||
describe('compressImageForModel — fallback', () => {
|
||
it('returns the original on corrupt bytes (never throws)', async () => {
|
||
// Valid PNG signature followed by garbage — decode will fail.
|
||
const corrupt = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4, 5]);
|
||
const result = await compressImageForModel(corrupt, 'image/png');
|
||
expect(result.changed).toBe(false);
|
||
expect(result.data).toBe(corrupt);
|
||
});
|
||
|
||
it('passes empty buffers through', async () => {
|
||
const empty = new Uint8Array(0);
|
||
const result = await compressImageForModel(empty, 'image/png');
|
||
expect(result.changed).toBe(false);
|
||
expect(result.data).toBe(empty);
|
||
});
|
||
|
||
it('passes GIF through (preserves animation)', async () => {
|
||
// Minimal GIF89a header — enough for the MIME guard to skip it.
|
||
const gif = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 1, 0, 1, 0]);
|
||
const result = await compressImageForModel(gif, 'image/gif');
|
||
expect(result.changed).toBe(false);
|
||
expect(result.data).toBe(gif);
|
||
});
|
||
|
||
it('passes undecodable WebP bytes through (never throws)', async () => {
|
||
// A bare RIFF/WEBP container header with no image payload: the sniffer
|
||
// reports no dimensions and the wasm decoder cannot decode it, so it
|
||
// passes through unchanged like any other undecodable input.
|
||
const webp = new Uint8Array([
|
||
0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50,
|
||
]);
|
||
const result = await compressImageForModel(webp, 'image/webp');
|
||
expect(result.changed).toBe(false);
|
||
expect(result.data).toBe(webp);
|
||
});
|
||
|
||
it('skips compression for absurd pixel counts without decoding (bomb guard)', async () => {
|
||
// A PNG header advertising 30000×30000 (900 MP) with no pixel data. The
|
||
// dimension sniff reads the IHDR; the guard must pass through before Jimp
|
||
// is ever invoked, so this completes instantly with no multi-GB bitmap.
|
||
const header = Buffer.alloc(24);
|
||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(header, 0);
|
||
header.writeUInt32BE(13, 8); // IHDR chunk length
|
||
header.write('IHDR', 12, 'latin1');
|
||
header.writeUInt32BE(30000, 16);
|
||
header.writeUInt32BE(30000, 20);
|
||
const bomb = new Uint8Array(header);
|
||
|
||
const result = await compressImageForModel(bomb, 'image/png');
|
||
expect(result.changed).toBe(false);
|
||
expect(result.data).toBe(bomb); // identity → Jimp was never called
|
||
});
|
||
|
||
it('skips compression for payloads over the byte cap without decoding', async () => {
|
||
// Over the edge (so not the fast path), but capped by maxDecodeBytes.
|
||
const png = await solidPng(3200, 100);
|
||
const result = await compressImageForModel(png, 'image/png', { maxDecodeBytes: 64 });
|
||
expect(result.changed).toBe(false);
|
||
expect(result.data).toBe(png); // passthrough → Jimp was never called
|
||
});
|
||
});
|
||
|
||
// ── invariants ───────────────────────────────────────────────────────
|
||
|
||
describe('compressImageForModel — invariants', () => {
|
||
it('changed always yields a within-cap, decodable payload', async () => {
|
||
const cases: Uint8Array[] = [
|
||
await solidPng(4500, 2250),
|
||
await noisePng(900, 900),
|
||
await translucentPng(3600, 1800),
|
||
];
|
||
for (const bytes of cases) {
|
||
const result = await compressImageForModel(bytes, 'image/png');
|
||
expect(result.finalByteLength).toBe(result.data.length);
|
||
if (result.changed) {
|
||
// A change is only kept when it helped: fewer bytes or fewer pixels.
|
||
const original = sniffImageDimensions(bytes)!;
|
||
const shrankBytes = result.finalByteLength < result.originalByteLength;
|
||
const shrankPixels = result.width * result.height < original.width * original.height;
|
||
expect(shrankBytes || shrankPixels).toBe(true);
|
||
// Dimensions never exceed the cap after a change.
|
||
expect(Math.max(result.width, result.height)).toBeLessThanOrEqual(MAX_IMAGE_EDGE_PX);
|
||
// The result must decode.
|
||
expect(sniffImageDimensions(result.data)).not.toBeNull();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// ── base64 wrapper ───────────────────────────────────────────────────
|
||
|
||
describe('compressBase64ForModel', () => {
|
||
it('round-trips an over-sized image', async () => {
|
||
const png = await noisePng(700, 700);
|
||
const base64 = Buffer.from(png).toString('base64');
|
||
const result = await compressBase64ForModel(base64, 'image/png', { byteBudget: 8 * 1024 });
|
||
expect(result.changed).toBe(true);
|
||
expect(result.finalByteLength).toBeLessThan(result.originalByteLength);
|
||
// The re-encoded base64 still decodes to a valid image.
|
||
const dims = sniffImageDimensions(Buffer.from(result.base64, 'base64'));
|
||
expect(dims).not.toBeNull();
|
||
});
|
||
|
||
it('returns the original base64 unchanged on the fast path', async () => {
|
||
const png = await solidPng(64, 64);
|
||
const base64 = Buffer.from(png).toString('base64');
|
||
const result = await compressBase64ForModel(base64, 'image/png');
|
||
expect(result.changed).toBe(false);
|
||
expect(result.base64).toBe(base64);
|
||
});
|
||
|
||
it('skips a base64 payload over the byte cap without decoding', async () => {
|
||
const png = await solidPng(3200, 100); // over edge, would otherwise compress
|
||
const base64 = Buffer.from(png).toString('base64');
|
||
const result = await compressBase64ForModel(base64, 'image/png', { maxDecodeBytes: 64 });
|
||
expect(result.changed).toBe(false);
|
||
expect(result.base64).toBe(base64); // unchanged → not decoded
|
||
});
|
||
});
|
||
|
||
// ── performance ──────────────────────────────────────────────────────
|
||
|
||
describe('compressImageForModel — performance', () => {
|
||
it('fast path is codec-free and quick across many calls', async () => {
|
||
const png = await solidPng(200, 200);
|
||
const start = performance.now();
|
||
for (let i = 0; i < 100; i += 1) {
|
||
const result = await compressImageForModel(png, 'image/png');
|
||
expect(result.data).toBe(png); // proves no decode/encode happened
|
||
}
|
||
const elapsed = performance.now() - start;
|
||
// 100 metadata-only checks should be well under 100ms.
|
||
expect(elapsed).toBeLessThan(100);
|
||
});
|
||
|
||
it('compresses a large image within a generous time bound', async () => {
|
||
const png = await solidPng(4500, 3000);
|
||
const start = performance.now();
|
||
const result = await compressImageForModel(png, 'image/png');
|
||
const elapsed = performance.now() - start;
|
||
expect(result.changed).toBe(true);
|
||
expect(elapsed).toBeLessThan(5000);
|
||
});
|
||
|
||
it('exposes a sane default budget', () => {
|
||
expect(IMAGE_BYTE_BUDGET).toBeGreaterThan(0);
|
||
expect(MAX_IMAGE_EDGE_PX).toBe(2000);
|
||
});
|
||
});
|
||
|
||
// ── default edge resolution (env + config) ──────────────────────────
|
||
|
||
describe('resolveMaxImageEdgePx', () => {
|
||
afterEach(() => {
|
||
vi.unstubAllEnvs();
|
||
setConfiguredMaxImageEdgePx(undefined);
|
||
});
|
||
|
||
it('defaults to the built-in ceiling', () => {
|
||
expect(resolveMaxImageEdgePx()).toBe(MAX_IMAGE_EDGE_PX);
|
||
});
|
||
|
||
it('uses the configured value when set, and clears with undefined', () => {
|
||
setConfiguredMaxImageEdgePx(1200);
|
||
expect(resolveMaxImageEdgePx()).toBe(1200);
|
||
setConfiguredMaxImageEdgePx(undefined);
|
||
expect(resolveMaxImageEdgePx()).toBe(MAX_IMAGE_EDGE_PX);
|
||
});
|
||
|
||
it('lets the env var override the configured value', () => {
|
||
setConfiguredMaxImageEdgePx(1200);
|
||
vi.stubEnv(MAX_IMAGE_EDGE_ENV, '900');
|
||
expect(resolveMaxImageEdgePx()).toBe(900);
|
||
});
|
||
|
||
it.each(['abc', '-100', '0', '1.5', ' '])('ignores the invalid env value "%s"', (raw) => {
|
||
vi.stubEnv(MAX_IMAGE_EDGE_ENV, raw);
|
||
expect(resolveMaxImageEdgePx()).toBe(MAX_IMAGE_EDGE_PX);
|
||
});
|
||
|
||
it('drives compressImageForModel when no explicit maxEdge is passed', async () => {
|
||
setConfiguredMaxImageEdgePx(1200);
|
||
const png = await solidPng(1600, 800);
|
||
const result = await compressImageForModel(png, 'image/png');
|
||
expect(result.changed).toBe(true);
|
||
expect(result.width).toBe(1200);
|
||
expect(result.height).toBe(600);
|
||
});
|
||
|
||
it('an explicit maxEdge option still wins over env and config', async () => {
|
||
setConfiguredMaxImageEdgePx(1200);
|
||
vi.stubEnv(MAX_IMAGE_EDGE_ENV, '900');
|
||
const png = await solidPng(1600, 800);
|
||
const result = await compressImageForModel(png, 'image/png', { maxEdge: 800 });
|
||
expect(result.changed).toBe(true);
|
||
expect(result.width).toBe(800);
|
||
expect(result.height).toBe(400);
|
||
});
|
||
|
||
it('drives cropImageForModel region fitting', async () => {
|
||
setConfiguredMaxImageEdgePx(400);
|
||
const png = await solidPng(1600, 800);
|
||
const result = await cropImageForModel(png, 'image/png', {
|
||
x: 0,
|
||
y: 0,
|
||
width: 800,
|
||
height: 800,
|
||
});
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
expect(Math.max(result.width, result.height)).toBe(400);
|
||
});
|
||
});
|
||
|
||
describe('resolveReadImageByteBudget', () => {
|
||
afterEach(() => {
|
||
vi.unstubAllEnvs();
|
||
setConfiguredReadImageByteBudget(undefined);
|
||
});
|
||
|
||
it('defaults to the built-in read budget', () => {
|
||
expect(READ_IMAGE_BYTE_BUDGET).toBe(256 * 1024);
|
||
expect(resolveReadImageByteBudget()).toBe(READ_IMAGE_BYTE_BUDGET);
|
||
});
|
||
|
||
it('uses the configured value when set, and clears with undefined', () => {
|
||
setConfiguredReadImageByteBudget(512 * 1024);
|
||
expect(resolveReadImageByteBudget()).toBe(512 * 1024);
|
||
setConfiguredReadImageByteBudget(undefined);
|
||
expect(resolveReadImageByteBudget()).toBe(READ_IMAGE_BYTE_BUDGET);
|
||
});
|
||
|
||
it('lets the env var override the configured value', () => {
|
||
setConfiguredReadImageByteBudget(512 * 1024);
|
||
vi.stubEnv(READ_IMAGE_BYTE_BUDGET_ENV, '100000');
|
||
expect(resolveReadImageByteBudget()).toBe(100000);
|
||
});
|
||
|
||
it.each(['abc', '-1', '0', '1.5', ' '])('ignores the invalid env value "%s"', (raw) => {
|
||
vi.stubEnv(READ_IMAGE_BYTE_BUDGET_ENV, raw);
|
||
expect(resolveReadImageByteBudget()).toBe(READ_IMAGE_BYTE_BUDGET);
|
||
});
|
||
});
|
||
|
||
// ── content-part helper ──────────────────────────────────────────────
|
||
|
||
describe('compressImageContentParts', () => {
|
||
function dataUrl(mime: string, bytes: Uint8Array): string {
|
||
return `data:${mime};base64,${Buffer.from(bytes).toString('base64')}`;
|
||
}
|
||
|
||
it('compresses an oversized inline image part, leaving other parts untouched', async () => {
|
||
const big = await solidPng(3600, 1800);
|
||
const parts = [
|
||
{ type: 'text' as const, text: 'look at this' },
|
||
{ type: 'image_url' as const, imageUrl: { url: dataUrl('image/png', big) } },
|
||
];
|
||
const { parts: out } = await compressImageContentParts(parts);
|
||
|
||
expect(out[0]).toEqual({ type: 'text', text: 'look at this' });
|
||
const imagePart = out[1];
|
||
if (imagePart?.type !== 'image_url') throw new Error('expected image_url');
|
||
const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(imagePart.imageUrl.url);
|
||
expect(match).not.toBeNull();
|
||
const dims = sniffImageDimensions(Buffer.from(match![2]!, 'base64'));
|
||
expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(MAX_IMAGE_EDGE_PX);
|
||
});
|
||
|
||
it('preserves the part identity for a within-budget image (no change)', async () => {
|
||
const small = await solidPng(48, 48);
|
||
const url = dataUrl('image/png', small);
|
||
const parts = [{ type: 'image_url' as const, imageUrl: { url } }];
|
||
const { parts: out } = await compressImageContentParts(parts);
|
||
expect(out[0]).toEqual({ type: 'image_url', imageUrl: { url } });
|
||
});
|
||
|
||
it('leaves remote (non-data) image URLs untouched', async () => {
|
||
const parts = [
|
||
{ type: 'image_url' as const, imageUrl: { url: 'https://example.com/pic.png' } },
|
||
];
|
||
const { parts: out } = await compressImageContentParts(parts);
|
||
expect(out[0]).toEqual({ type: 'image_url', imageUrl: { url: 'https://example.com/pic.png' } });
|
||
});
|
||
|
||
it('keeps an image part id when rewriting the compressed url', async () => {
|
||
const big = await solidPng(3600, 1800);
|
||
const parts = [
|
||
{ type: 'image_url' as const, imageUrl: { url: dataUrl('image/png', big), id: 'att-1' } },
|
||
];
|
||
const { parts: out } = await compressImageContentParts(parts);
|
||
const imagePart = out[0];
|
||
if (imagePart?.type !== 'image_url') throw new Error('expected image_url');
|
||
expect(imagePart.imageUrl.id).toBe('att-1');
|
||
expect(imagePart.imageUrl.url).not.toBe(dataUrl('image/png', big));
|
||
});
|
||
});
|
||
|
||
// ── original-dimension metadata ──────────────────────────────────────
|
||
|
||
describe('compressImageForModel — EXIF orientation', () => {
|
||
it('reports original dimensions in the decoded (EXIF-rotated) space', async () => {
|
||
// Orientation 6 (rotate 90° CW): the file header says 120x80, but jimp
|
||
// decodes to 80x120 — the space the sent image and any later crop region
|
||
// actually live in. The reported original dimensions must match it, not
|
||
// the pre-rotation header sniff.
|
||
const jpeg = withExifOrientation(await solidJpeg(120, 80), 6);
|
||
const result = await compressImageForModel(jpeg, 'image/jpeg', { maxEdge: 64 });
|
||
expect(result.changed).toBe(true);
|
||
expect(result.originalWidth).toBe(80);
|
||
expect(result.originalHeight).toBe(120);
|
||
// The sent image keeps the rotated (portrait) aspect.
|
||
expect(result.width).toBeLessThan(result.height);
|
||
});
|
||
|
||
it('reports display-space dimensions for an EXIF-rotated passthrough', async () => {
|
||
// Within both budgets → no decode ever happens. The header sniff itself
|
||
// must account for EXIF orientation so passthrough metadata agrees with
|
||
// the space a later region readback (which decodes) will use.
|
||
const jpeg = withExifOrientation(await solidJpeg(120, 80), 6);
|
||
const result = await compressImageForModel(jpeg, 'image/jpeg');
|
||
expect(result.changed).toBe(false);
|
||
expect(result.data).toBe(jpeg); // fast path — not decoded
|
||
expect(result.originalWidth).toBe(80);
|
||
expect(result.originalHeight).toBe(120);
|
||
expect(result.width).toBe(80);
|
||
expect(result.height).toBe(120);
|
||
});
|
||
});
|
||
|
||
describe('compressImageForModel — original dimensions metadata', () => {
|
||
it('reports original dimensions on passthrough and compressed results', async () => {
|
||
const small = await solidPng(64, 64);
|
||
const pass = await compressImageForModel(small, 'image/png');
|
||
expect(pass.changed).toBe(false);
|
||
expect(pass.originalWidth).toBe(64);
|
||
expect(pass.originalHeight).toBe(64);
|
||
|
||
const big = await solidPng(4500, 2250);
|
||
const shrunk = await compressImageForModel(big, 'image/png');
|
||
expect(shrunk.changed).toBe(true);
|
||
expect(shrunk.originalWidth).toBe(4500);
|
||
expect(shrunk.originalHeight).toBe(2250);
|
||
expect(shrunk.width).toBe(2000);
|
||
});
|
||
|
||
it('reports original dimensions through the base64 wrapper', async () => {
|
||
const big = await solidPng(3900, 1950);
|
||
const base64 = Buffer.from(big).toString('base64');
|
||
const result = await compressBase64ForModel(base64, 'image/png');
|
||
expect(result.changed).toBe(true);
|
||
expect(result.originalWidth).toBe(3900);
|
||
expect(result.originalHeight).toBe(1950);
|
||
expect(result.width).toBe(2000);
|
||
expect(result.height).toBe(1000);
|
||
});
|
||
});
|
||
|
||
// ── crop ─────────────────────────────────────────────────────────────
|
||
|
||
describe('cropImageForModel', () => {
|
||
it('crops a region out of a PNG at native resolution', async () => {
|
||
const png = await solidPng(3000, 1500);
|
||
const result = await cropImageForModel(png, 'image/png', {
|
||
x: 100,
|
||
y: 200,
|
||
width: 500,
|
||
height: 400,
|
||
});
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
expect(result.width).toBe(500);
|
||
expect(result.height).toBe(400);
|
||
expect(result.originalWidth).toBe(3000);
|
||
expect(result.originalHeight).toBe(1500);
|
||
expect(result.region).toEqual({ x: 100, y: 200, width: 500, height: 400 });
|
||
expect(result.resized).toBe(false);
|
||
expect(result.mimeType).toBe('image/png');
|
||
expect(sniffImageDimensions(result.data)).toEqual({ width: 500, height: 400 });
|
||
});
|
||
|
||
it('preserves the JPEG format when cropping a JPEG', async () => {
|
||
const jpeg = await solidJpeg(2400, 1200);
|
||
const result = await cropImageForModel(jpeg, 'image/jpeg', {
|
||
x: 0,
|
||
y: 0,
|
||
width: 300,
|
||
height: 300,
|
||
});
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
expect(result.mimeType).toBe('image/jpeg');
|
||
expect(result.width).toBe(300);
|
||
expect(result.height).toBe(300);
|
||
});
|
||
|
||
it('clamps a region that overflows the image bounds', async () => {
|
||
const png = await solidPng(3000, 1500);
|
||
const result = await cropImageForModel(png, 'image/png', {
|
||
x: 2500,
|
||
y: 1000,
|
||
width: 1000,
|
||
height: 1000,
|
||
});
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
expect(result.region).toEqual({ x: 2500, y: 1000, width: 500, height: 500 });
|
||
expect(result.width).toBe(500);
|
||
expect(result.height).toBe(500);
|
||
});
|
||
|
||
it('rejects a region fully outside the image, naming the original size', async () => {
|
||
const png = await solidPng(3000, 1500);
|
||
const result = await cropImageForModel(png, 'image/png', {
|
||
x: 3000,
|
||
y: 0,
|
||
width: 100,
|
||
height: 100,
|
||
});
|
||
expect(result.ok).toBe(false);
|
||
if (result.ok) return;
|
||
expect(result.error).toContain('3000x1500');
|
||
});
|
||
|
||
it('downscales an oversized crop to the edge cap by default', async () => {
|
||
const png = await solidPng(4500, 2250);
|
||
const result = await cropImageForModel(png, 'image/png', {
|
||
x: 0,
|
||
y: 0,
|
||
width: 4000,
|
||
height: 1200,
|
||
});
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
expect(result.resized).toBe(true);
|
||
expect(Math.max(result.width, result.height)).toBeLessThanOrEqual(MAX_IMAGE_EDGE_PX);
|
||
expect(result.region).toEqual({ x: 0, y: 0, width: 4000, height: 1200 });
|
||
});
|
||
|
||
it('keeps native resolution with skipResize', async () => {
|
||
const png = await solidPng(3000, 1500);
|
||
const result = await cropImageForModel(
|
||
png,
|
||
'image/png',
|
||
{ x: 0, y: 0, width: 2500, height: 1200 },
|
||
{ skipResize: true },
|
||
);
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
expect(result.resized).toBe(false);
|
||
expect(result.width).toBe(2500);
|
||
expect(result.height).toBe(1200);
|
||
});
|
||
|
||
it('fails explicitly when a skipResize crop exceeds the byte budget', async () => {
|
||
const png = await noisePng(900, 900);
|
||
const result = await cropImageForModel(
|
||
png,
|
||
'image/png',
|
||
{ x: 0, y: 0, width: 900, height: 900 },
|
||
{ skipResize: true, byteBudget: 8 * 1024 },
|
||
);
|
||
expect(result.ok).toBe(false);
|
||
if (result.ok) return;
|
||
expect(result.error).toMatch(/smaller region/i);
|
||
});
|
||
|
||
it('rejects non-recodable formats explicitly', async () => {
|
||
const gif = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 1, 0, 1, 0]);
|
||
const result = await cropImageForModel(gif, 'image/gif', {
|
||
x: 0,
|
||
y: 0,
|
||
width: 1,
|
||
height: 1,
|
||
});
|
||
expect(result.ok).toBe(false);
|
||
if (result.ok) return;
|
||
expect(result.error).toMatch(/PNG, JPEG, and WebP/);
|
||
});
|
||
|
||
it('rejects corrupt bytes without throwing', async () => {
|
||
const corrupt = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
|
||
const result = await cropImageForModel(corrupt, 'image/png', {
|
||
x: 0,
|
||
y: 0,
|
||
width: 10,
|
||
height: 10,
|
||
});
|
||
expect(result.ok).toBe(false);
|
||
});
|
||
|
||
it('rejects non-finite region coordinates with a clean error', async () => {
|
||
// NaN slips past every `<`/`>=` comparison, so without an explicit guard
|
||
// it reaches jimp and surfaces as a misleading internal validation dump.
|
||
const png = await solidPng(300, 200);
|
||
for (const region of [
|
||
{ x: Number.NaN, y: 0, width: 10, height: 10 },
|
||
{ x: 0, y: Number.NaN, width: 10, height: 10 },
|
||
{ x: 0, y: 0, width: Number.NaN, height: 10 },
|
||
{ x: 0, y: 0, width: 10, height: Number.NaN },
|
||
]) {
|
||
const result = await cropImageForModel(png, 'image/png', region);
|
||
expect(result.ok).toBe(false);
|
||
if (result.ok) continue;
|
||
expect(result.error).toMatch(/finite/i);
|
||
expect(result.error).not.toMatch(/Failed to decode/);
|
||
}
|
||
});
|
||
|
||
it('refuses to decode a decompression bomb', async () => {
|
||
const header = Buffer.alloc(24);
|
||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(header, 0);
|
||
header.writeUInt32BE(13, 8);
|
||
header.write('IHDR', 12, 'latin1');
|
||
header.writeUInt32BE(30000, 16);
|
||
header.writeUInt32BE(30000, 20);
|
||
const result = await cropImageForModel(new Uint8Array(header), 'image/png', {
|
||
x: 0,
|
||
y: 0,
|
||
width: 10,
|
||
height: 10,
|
||
});
|
||
expect(result.ok).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ── compression caption ──────────────────────────────────────────────
|
||
|
||
describe('buildImageCompressionCaption', () => {
|
||
it('describes the original and sent variants with a readback path', () => {
|
||
const caption = buildImageCompressionCaption({
|
||
original: { width: 5184, height: 3456, byteLength: 13002342, mimeType: 'image/png' },
|
||
final: { width: 2000, height: 1333, byteLength: 1153433, mimeType: 'image/jpeg' },
|
||
originalPath: '/tmp/originals/ab.png',
|
||
});
|
||
expect(caption).toMatch(/^<system>.*<\/system>$/s);
|
||
expect(caption).toContain('5184x3456 image/png (12.4 MB)');
|
||
expect(caption).toContain('2000x1333 image/jpeg (1.1 MB)');
|
||
expect(caption).toContain('/tmp/originals/ab.png');
|
||
expect(caption).toContain('region');
|
||
});
|
||
|
||
it('omits dimensions when unknown and notes a missing original', () => {
|
||
const caption = buildImageCompressionCaption({
|
||
original: { width: 0, height: 0, byteLength: 5 * 1024 * 1024, mimeType: 'image/png' },
|
||
final: { width: 0, height: 0, byteLength: 1024 * 1024, mimeType: 'image/jpeg' },
|
||
});
|
||
expect(caption).not.toContain('0x0');
|
||
expect(caption).toContain('image/png (5.0 MB)');
|
||
expect(caption).toContain('image/jpeg (1.0 MB)');
|
||
expect(caption).toMatch(/not preserved/i);
|
||
});
|
||
});
|
||
|
||
describe('extractImageCompressionCaptions', () => {
|
||
const caption = buildImageCompressionCaption({
|
||
original: { width: 3264, height: 666, byteLength: 344 * 1024, mimeType: 'image/png' },
|
||
final: { width: 2000, height: 408, byteLength: 282 * 1024, mimeType: 'image/png' },
|
||
originalPath: '/tmp/originals/shot.png',
|
||
});
|
||
|
||
it('extracts a standalone caption, unwrapping the <system> tag', () => {
|
||
const result = extractImageCompressionCaptions(caption);
|
||
expect(result.captions).toHaveLength(1);
|
||
expect(result.captions[0]).toContain('Image compressed to fit model limits');
|
||
expect(result.captions[0]).toContain('/tmp/originals/shot.png');
|
||
expect(result.captions[0]).not.toContain('<system>');
|
||
expect(result.text).toBe('');
|
||
});
|
||
|
||
it('extracts a caption merged into surrounding user text', () => {
|
||
const result = extractImageCompressionCaptions(`能展示但是没有快捷键提示${caption}`);
|
||
expect(result.captions).toHaveLength(1);
|
||
expect(result.text).toBe('能展示但是没有快捷键提示');
|
||
});
|
||
|
||
it('extracts multiple captions from one text', () => {
|
||
const other = buildImageCompressionCaption({
|
||
original: { width: 4000, height: 3000, byteLength: 9 * 1024 * 1024, mimeType: 'image/jpeg' },
|
||
final: { width: 2000, height: 1500, byteLength: 1024 * 1024, mimeType: 'image/jpeg' },
|
||
originalPath: '/tmp/originals/photo.jpg',
|
||
});
|
||
const result = extractImageCompressionCaptions(`看这两张图${caption}${other}`);
|
||
expect(result.captions).toHaveLength(2);
|
||
expect(result.captions[0]).toContain('/tmp/originals/shot.png');
|
||
expect(result.captions[1]).toContain('/tmp/originals/photo.jpg');
|
||
expect(result.text).toBe('看这两张图');
|
||
});
|
||
|
||
it('leaves non-caption <system> blocks and plain text untouched', () => {
|
||
const toolStatus = '<system>ERROR: Tool execution failed.</system>';
|
||
expect(extractImageCompressionCaptions(toolStatus)).toEqual({
|
||
captions: [],
|
||
text: toolStatus,
|
||
});
|
||
expect(extractImageCompressionCaptions('just some text')).toEqual({
|
||
captions: [],
|
||
text: 'just some text',
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── content-part annotation ──────────────────────────────────────────
|
||
|
||
describe('compressImageContentParts — annotate', () => {
|
||
function dataUrl(mime: string, bytes: Uint8Array): string {
|
||
return `data:${mime};base64,${Buffer.from(bytes).toString('base64')}`;
|
||
}
|
||
|
||
it('collects a caption for a compressed image and persists the original', async () => {
|
||
const big = await solidPng(3600, 1800);
|
||
const persisted: { bytes: Uint8Array; mimeType: string }[] = [];
|
||
const parts = [{ type: 'image_url' as const, imageUrl: { url: dataUrl('image/png', big) } }];
|
||
const out = await compressImageContentParts(parts, {
|
||
annotate: {
|
||
persistOriginal: (bytes, mimeType) => {
|
||
persisted.push({ bytes, mimeType });
|
||
return Promise.resolve('/tmp/originals/big.png');
|
||
},
|
||
},
|
||
});
|
||
|
||
// The caption comes back as data, never inserted into the parts.
|
||
expect(out.parts).toHaveLength(1);
|
||
expect(out.parts[0]?.type).toBe('image_url');
|
||
expect(out.captions).toHaveLength(1);
|
||
expect(out.captions[0]).toContain('3600x1800');
|
||
expect(out.captions[0]).toContain('/tmp/originals/big.png');
|
||
expect(persisted).toHaveLength(1);
|
||
expect(persisted[0]?.mimeType).toBe('image/png');
|
||
expect(persisted[0]?.bytes.length).toBe(big.length);
|
||
});
|
||
|
||
it('collects no caption when the image passes through unchanged', async () => {
|
||
const small = await solidPng(48, 48);
|
||
const url = dataUrl('image/png', small);
|
||
const out = await compressImageContentParts([{ type: 'image_url' as const, imageUrl: { url } }], {
|
||
annotate: {},
|
||
});
|
||
expect(out.parts).toHaveLength(1);
|
||
expect(out.parts[0]).toEqual({ type: 'image_url', imageUrl: { url } });
|
||
expect(out.captions).toEqual([]);
|
||
});
|
||
|
||
it('captions without a path when persistence fails', async () => {
|
||
const big = await solidPng(3600, 1800);
|
||
const parts = [{ type: 'image_url' as const, imageUrl: { url: dataUrl('image/png', big) } }];
|
||
const out = await compressImageContentParts(parts, {
|
||
annotate: { persistOriginal: () => Promise.resolve(null) },
|
||
});
|
||
expect(out.parts).toHaveLength(1);
|
||
expect(out.captions).toHaveLength(1);
|
||
expect(out.captions[0]).toMatch(/not preserved/i);
|
||
});
|
||
});
|
||
|
||
// ── downscale quality guards ─────────────────────────────────────────
|
||
//
|
||
// Downscaling is a resampling operation: input frequencies above the output
|
||
// Nyquist limit must be filtered out (averaged), or they fold back as
|
||
// low-frequency moiré — spectral aliasing. A 1px checkerboard is the
|
||
// worst-case probe: ALL of its energy sits at the input Nyquist frequency,
|
||
// so a resampler that skips source pixels turns it into high-contrast
|
||
// artifacts, while a correct full-coverage average yields flat ~50% gray.
|
||
// These tests pin the compressor to the correct behavior, keep the aliasing
|
||
// counter-example executable, and cover the other classic downscale bugs
|
||
// (transparent-pixel bleed, brightness drift, iterative degradation,
|
||
// degenerate aspect ratios).
|
||
|
||
/** 1px checkerboard: every pixel alternates black/white in both axes. */
|
||
async function checkerboardPng(size: number): Promise<Uint8Array> {
|
||
const image = new Jimp({ width: size, height: size, color: 0x000000ff });
|
||
const data = image.bitmap.data;
|
||
for (let y = 0; y < size; y += 1) {
|
||
for (let x = 0; x < size; x += 1) {
|
||
const v = (x + y) % 2 === 0 ? 0 : 255;
|
||
const i = (y * size + x) * 4;
|
||
data[i] = v;
|
||
data[i + 1] = v;
|
||
data[i + 2] = v;
|
||
data[i + 3] = 0xff;
|
||
}
|
||
}
|
||
return new Uint8Array(await image.getBuffer('image/png'));
|
||
}
|
||
|
||
interface GrayStats {
|
||
readonly min: number;
|
||
readonly max: number;
|
||
readonly mean: number;
|
||
}
|
||
|
||
/** Min/max/mean over the red channel (all probes here are grayscale). */
|
||
function grayStats(image: { bitmap: { data: Buffer | Uint8Array } }): GrayStats {
|
||
const data = image.bitmap.data;
|
||
let min = 255;
|
||
let max = 0;
|
||
let sum = 0;
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
const v = data[i]!;
|
||
if (v < min) min = v;
|
||
if (v > max) max = v;
|
||
sum += v;
|
||
}
|
||
return { min, max, mean: sum / (data.length / 4) };
|
||
}
|
||
|
||
describe('compressImageForModel — downscale quality guards', () => {
|
||
it('averages a 1px checkerboard to flat gray at an integer ratio (no aliasing)', async () => {
|
||
// 2000 → 500 (4:1). Every output pixel covers a 4×4 block holding 8
|
||
// black and 8 white pixels, so a full-coverage average lands on ~127.
|
||
// Aliasing would instead show up as black/white patches or moiré bands.
|
||
const png = await checkerboardPng(2000);
|
||
const result = await compressImageForModel(png, 'image/png', { maxEdge: 500 });
|
||
expect(result.changed).toBe(true);
|
||
expect(Math.max(result.width, result.height)).toBe(500);
|
||
|
||
const decoded = await Jimp.fromBuffer(Buffer.from(result.data));
|
||
const { min, max } = grayStats(decoded);
|
||
expect(min).toBeGreaterThanOrEqual(118);
|
||
expect(max).toBeLessThanOrEqual(138);
|
||
});
|
||
|
||
it('stays alias-free at a non-integer ratio (fractional pixel coverage)', async () => {
|
||
// 2000 → 780 (≈2.56:1). Non-integer ratios are where phase-dependent
|
||
// point sampling degrades worst. Fractional window coverage leaves the
|
||
// average some mild texture, but nothing may approach black or white.
|
||
const png = await checkerboardPng(2000);
|
||
const result = await compressImageForModel(png, 'image/png', { maxEdge: 780 });
|
||
expect(result.changed).toBe(true);
|
||
|
||
const decoded = await Jimp.fromBuffer(Buffer.from(result.data));
|
||
const { min, max } = grayStats(decoded);
|
||
expect(min).toBeGreaterThanOrEqual(90);
|
||
expect(max).toBeLessThanOrEqual(165);
|
||
});
|
||
|
||
it('control: jimp point-sampled BILINEAR aliases the same input (keeps the probe honest)', async () => {
|
||
// Executable counter-example for the constraint documented on
|
||
// fitWithinEdge: the named ResizeStrategy modes sample a fixed 2×2
|
||
// neighborhood around the mapped point and skip the rest. At 4:1 the
|
||
// sample grid lands on a single checkerboard phase and the 50%-gray
|
||
// pattern collapses to solid black — the pattern's energy is entirely
|
||
// misrepresented. This proves the two tests above can fail (the probe
|
||
// distinguishes resamplers) and pins the library behavior the
|
||
// mode-less default call relies on — if jimp ever changes either
|
||
// side, revisit the fitWithinEdge comment.
|
||
const image = await Jimp.fromBuffer(Buffer.from(await checkerboardPng(2000)));
|
||
image.resize({ w: 500, h: 500, mode: ResizeStrategy.BILINEAR });
|
||
const { min, max, mean } = grayStats(image);
|
||
// The correct answer is flat ~127 gray (mean ≈ 127, max-min ≈ 0).
|
||
// Aliasing shows up as a solid black/white collapse or full-contrast
|
||
// banding — far from that answer regardless of sampling phase.
|
||
const aliased = mean < 60 || mean > 195 || max - min > 200;
|
||
expect(aliased).toBe(true);
|
||
});
|
||
|
||
it('never bleeds color from fully transparent pixels into visible ones', async () => {
|
||
// Fully transparent pixels still carry RGB values. A resizer that
|
||
// blends them into the average tints every transparency edge (halo).
|
||
// Probe: a fully transparent BRIGHT RED field around an opaque blue
|
||
// square — after a 4:1 downscale no visible pixel may pick up red.
|
||
const size = 1600;
|
||
const image = new Jimp({ width: size, height: size, color: 0xff000000 }); // red, alpha 0
|
||
const data = image.bitmap.data;
|
||
for (let y = 400; y < 1200; y += 1) {
|
||
for (let x = 400; x < 1200; x += 1) {
|
||
const i = (y * size + x) * 4;
|
||
data[i] = 0;
|
||
data[i + 1] = 0;
|
||
data[i + 2] = 0xff;
|
||
data[i + 3] = 0xff;
|
||
}
|
||
}
|
||
const png = new Uint8Array(await image.getBuffer('image/png'));
|
||
|
||
const result = await compressImageForModel(png, 'image/png', { maxEdge: 400 });
|
||
expect(result.changed).toBe(true);
|
||
expect(result.mimeType).toBe('image/png'); // alpha survives
|
||
|
||
const decoded = await Jimp.fromBuffer(Buffer.from(result.data));
|
||
const out = decoded.bitmap.data;
|
||
let visible = 0;
|
||
for (let i = 0; i < out.length; i += 4) {
|
||
if (out[i + 3]! >= 8) {
|
||
visible += 1;
|
||
expect(out[i]!).toBeLessThanOrEqual(16); // red channel stays ~0
|
||
}
|
||
}
|
||
expect(visible).toBeGreaterThan(0); // the blue square is still there
|
||
});
|
||
|
||
it('preserves mean brightness through the downscale (no energy drift)', async () => {
|
||
// A normalized filter keeps the image mean; drift here would indicate
|
||
// non-normalized weights (or a broken gamma pipeline stage).
|
||
const png = await noisePng(900, 900);
|
||
const input = await Jimp.fromBuffer(Buffer.from(png));
|
||
const inputMean = grayStats(input).mean;
|
||
|
||
const result = await compressImageForModel(png, 'image/png', { maxEdge: 225 });
|
||
expect(result.changed).toBe(true);
|
||
const output = await Jimp.fromBuffer(Buffer.from(result.data));
|
||
expect(Math.abs(grayStats(output).mean - inputMean)).toBeLessThan(3);
|
||
});
|
||
|
||
it('recompressing a compressed result is a no-op (no iterative degradation)', async () => {
|
||
// Model-bound bytes can re-enter the pipeline (session replay, MCP
|
||
// round-trips). Once within budget they must pass through untouched
|
||
// instead of being shaved a little smaller on every pass.
|
||
const first = await compressImageForModel(await solidPng(4500, 2250), 'image/png');
|
||
expect(first.changed).toBe(true);
|
||
|
||
const second = await compressImageForModel(first.data, first.mimeType);
|
||
expect(second.changed).toBe(false);
|
||
expect(second.data).toBe(first.data); // identity — not even re-decoded
|
||
});
|
||
|
||
it('keeps a degenerate aspect ratio at least 1px tall (no zero-size collapse)', async () => {
|
||
// 9000×2 scaled to a 2000px edge would round the short side to 0.44px;
|
||
// the resizer must clamp to 1, not produce an undecodable 2000×0 image.
|
||
const png = await solidPng(9000, 2);
|
||
const result = await compressImageForModel(png, 'image/png');
|
||
expect(result.changed).toBe(true);
|
||
expect(result.width).toBe(2000);
|
||
expect(result.height).toBe(1);
|
||
expect(sniffImageDimensions(result.data)).toEqual({ width: 2000, height: 1 });
|
||
});
|
||
});
|
||
|
||
// ── telemetry ────────────────────────────────────────────────────────
|
||
|
||
interface CapturedEvent {
|
||
readonly event: string;
|
||
readonly props: TelemetryProperties;
|
||
}
|
||
|
||
function captureTelemetry(): { client: TelemetryClient; events: CapturedEvent[] } {
|
||
const events: CapturedEvent[] = [];
|
||
return {
|
||
client: { track: (event, props) => events.push({ event, props: props ?? {} }) },
|
||
events,
|
||
};
|
||
}
|
||
|
||
describe('compressImageForModel — telemetry', () => {
|
||
it('reports a compressed image with sizes, formats, and duration', async () => {
|
||
const { client, events } = captureTelemetry();
|
||
const png = await solidPng(4500, 2250);
|
||
const result = await compressImageForModel(png, 'image/png', {
|
||
telemetry: { client, source: 'read_media' },
|
||
});
|
||
expect(result.changed).toBe(true);
|
||
|
||
expect(events).toHaveLength(1);
|
||
const { event, props } = events[0]!;
|
||
expect(event).toBe('image_compress');
|
||
expect(props['source']).toBe('read_media');
|
||
expect(props['outcome']).toBe('compressed');
|
||
expect(props['input_mime']).toBe('image/png');
|
||
expect(props['output_mime']).toBe(result.mimeType);
|
||
expect(props['original_bytes']).toBe(png.length);
|
||
expect(props['final_bytes']).toBe(result.finalByteLength);
|
||
expect(props['original_width']).toBe(4500);
|
||
expect(props['original_height']).toBe(2250);
|
||
expect(props['final_width']).toBe(2000);
|
||
expect(props['final_height']).toBe(1000);
|
||
expect(props['exif_transposed']).toBe(false);
|
||
expect(typeof props['duration_ms']).toBe('number');
|
||
});
|
||
|
||
it('reports the fast path as passthrough_fast', async () => {
|
||
const { client, events } = captureTelemetry();
|
||
await compressImageForModel(await solidPng(64, 64), 'image/png', {
|
||
telemetry: { client, source: 'tui_paste' },
|
||
});
|
||
expect(events).toHaveLength(1);
|
||
expect(events[0]!.props['outcome']).toBe('passthrough_fast');
|
||
expect(events[0]!.props['source']).toBe('tui_paste');
|
||
});
|
||
|
||
it('reports decode guards as passthrough_guard', async () => {
|
||
// Decompression-bomb header: 30000×30000 with no pixel data.
|
||
const header = Buffer.alloc(24);
|
||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(header, 0);
|
||
header.writeUInt32BE(13, 8);
|
||
header.write('IHDR', 12, 'latin1');
|
||
header.writeUInt32BE(30000, 16);
|
||
header.writeUInt32BE(30000, 20);
|
||
|
||
const bomb = captureTelemetry();
|
||
await compressImageForModel(new Uint8Array(header), 'image/png', {
|
||
telemetry: { client: bomb.client, source: 'mcp_tool_result' },
|
||
});
|
||
expect(bomb.events[0]!.props['outcome']).toBe('passthrough_guard');
|
||
|
||
const byteCap = captureTelemetry();
|
||
await compressImageForModel(await solidPng(3200, 100), 'image/png', {
|
||
maxDecodeBytes: 64,
|
||
telemetry: { client: byteCap.client, source: 'mcp_tool_result' },
|
||
});
|
||
expect(byteCap.events[0]!.props['outcome']).toBe('passthrough_guard');
|
||
});
|
||
|
||
it('reports non-recodable formats and empty input as passthrough_unsupported', async () => {
|
||
const gif = captureTelemetry();
|
||
await compressImageForModel(
|
||
new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 1, 0, 1, 0]),
|
||
'image/gif',
|
||
{ telemetry: { client: gif.client, source: 'mcp_tool_result' } },
|
||
);
|
||
expect(gif.events[0]!.props['outcome']).toBe('passthrough_unsupported');
|
||
|
||
const empty = captureTelemetry();
|
||
await compressImageForModel(new Uint8Array(0), 'image/png', {
|
||
telemetry: { client: empty.client, source: 'mcp_tool_result' },
|
||
});
|
||
expect(empty.events[0]!.props['outcome']).toBe('passthrough_unsupported');
|
||
});
|
||
|
||
it('reports undecodable bytes as passthrough_error', async () => {
|
||
// A tiny corrupt blob would pass through on the fast path (unknown dims,
|
||
// small bytes) without ever decoding; to reach the decoder the header
|
||
// must claim an over-cap size. 4000×4000 forces a decode of garbage.
|
||
const { client, events } = captureTelemetry();
|
||
const corrupt = Buffer.alloc(32);
|
||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(corrupt, 0);
|
||
corrupt.writeUInt32BE(13, 8);
|
||
corrupt.write('IHDR', 12, 'latin1');
|
||
corrupt.writeUInt32BE(4000, 16);
|
||
corrupt.writeUInt32BE(4000, 20);
|
||
await compressImageForModel(new Uint8Array(corrupt), 'image/png', {
|
||
telemetry: { client, source: 'prompt_inline' },
|
||
});
|
||
expect(events[0]!.props['outcome']).toBe('passthrough_error');
|
||
});
|
||
|
||
it('marks EXIF-transposed inputs', async () => {
|
||
const { client, events } = captureTelemetry();
|
||
const jpeg = withExifOrientation(await solidJpeg(120, 80), 6);
|
||
await compressImageForModel(jpeg, 'image/jpeg', {
|
||
maxEdge: 64,
|
||
telemetry: { client, source: 'read_media' },
|
||
});
|
||
expect(events[0]!.props['outcome']).toBe('compressed');
|
||
expect(events[0]!.props['exif_transposed']).toBe(true);
|
||
});
|
||
|
||
it('reports the base64 early size-skip as passthrough_guard', async () => {
|
||
const { client, events } = captureTelemetry();
|
||
const base64 = Buffer.from(await solidPng(3200, 100)).toString('base64');
|
||
await compressBase64ForModel(base64, 'image/png', {
|
||
maxDecodeBytes: 64,
|
||
telemetry: { client, source: 'prompt_file' },
|
||
});
|
||
expect(events).toHaveLength(1);
|
||
expect(events[0]!.props['outcome']).toBe('passthrough_guard');
|
||
expect(events[0]!.props['source']).toBe('prompt_file');
|
||
});
|
||
|
||
it('threads telemetry through compressImageContentParts', async () => {
|
||
const { client, events } = captureTelemetry();
|
||
const big = await solidPng(3600, 1800);
|
||
const url = `data:image/png;base64,${Buffer.from(big).toString('base64')}`;
|
||
await compressImageContentParts([{ type: 'image_url', imageUrl: { url } }], {
|
||
telemetry: { client, source: 'mcp_tool_result' },
|
||
});
|
||
expect(events).toHaveLength(1);
|
||
expect(events[0]!.event).toBe('image_compress');
|
||
expect(events[0]!.props['outcome']).toBe('compressed');
|
||
expect(events[0]!.props['source']).toBe('mcp_tool_result');
|
||
});
|
||
|
||
it('never lets a throwing telemetry client break compression', async () => {
|
||
const throwing: TelemetryClient = {
|
||
track: () => {
|
||
throw new Error('sink down');
|
||
},
|
||
};
|
||
const png = await solidPng(4500, 2250);
|
||
const result = await compressImageForModel(png, 'image/png', {
|
||
telemetry: { client: throwing, source: 'read_media' },
|
||
});
|
||
expect(result.changed).toBe(true); // compression outcome unaffected
|
||
});
|
||
});
|
||
|
||
describe('cropImageForModel — telemetry', () => {
|
||
it('reports a successful crop with the region share of the original', async () => {
|
||
const { client, events } = captureTelemetry();
|
||
const png = await solidPng(1000, 500);
|
||
const outcome = await cropImageForModel(
|
||
png,
|
||
'image/png',
|
||
{ x: 0, y: 0, width: 500, height: 250 },
|
||
{ telemetry: { client, source: 'read_media' } },
|
||
);
|
||
expect(outcome.ok).toBe(true);
|
||
|
||
expect(events).toHaveLength(1);
|
||
const { event, props } = events[0]!;
|
||
expect(event).toBe('image_crop');
|
||
expect(props['source']).toBe('read_media');
|
||
expect(props['ok']).toBe(true);
|
||
expect(props['resized']).toBe(false);
|
||
expect(props['original_width']).toBe(1000);
|
||
expect(props['original_height']).toBe(500);
|
||
// 500×250 of 1000×500 → a quarter of the pixels.
|
||
expect(props['region_area_ratio']).toBeCloseTo(0.25, 5);
|
||
expect(typeof props['duration_ms']).toBe('number');
|
||
expect(typeof props['final_bytes']).toBe('number');
|
||
});
|
||
|
||
it('classifies failures by kind', async () => {
|
||
const oob = captureTelemetry();
|
||
await cropImageForModel(
|
||
await solidPng(100, 100),
|
||
'image/png',
|
||
{ x: 200, y: 0, width: 10, height: 10 },
|
||
{ telemetry: { client: oob.client, source: 'read_media' } },
|
||
);
|
||
expect(oob.events[0]!.props['ok']).toBe(false);
|
||
expect(oob.events[0]!.props['error_kind']).toBe('out_of_bounds');
|
||
|
||
const format = captureTelemetry();
|
||
await cropImageForModel(
|
||
new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 1, 0, 1, 0]),
|
||
'image/gif',
|
||
{ x: 0, y: 0, width: 1, height: 1 },
|
||
{ telemetry: { client: format.client, source: 'read_media' } },
|
||
);
|
||
expect(format.events[0]!.props['error_kind']).toBe('unsupported_format');
|
||
|
||
const budget = captureTelemetry();
|
||
await cropImageForModel(
|
||
await noisePng(900, 900),
|
||
'image/png',
|
||
{ x: 0, y: 0, width: 900, height: 900 },
|
||
{ skipResize: true, byteBudget: 8 * 1024, telemetry: { client: budget.client, source: 'read_media' } },
|
||
);
|
||
expect(budget.events[0]!.props['error_kind']).toBe('budget');
|
||
});
|
||
});
|