diff --git a/packages/coding-agent/src/utils/image-resize-core.ts b/packages/coding-agent/src/utils/image-resize-core.ts index 3f80111ff..d4a4d8cab 100644 --- a/packages/coding-agent/src/utils/image-resize-core.ts +++ b/packages/coding-agent/src/utils/image-resize-core.ts @@ -134,9 +134,11 @@ export async function resizeImageInProcess( let currentHeight = targetHeight; while (true) { - const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps); - for (const candidate of candidates) { - if (candidate.encodedSize < opts.maxBytes) { + const [png, jpeg, ...lowerQualityJpegs] = tryEncodings(currentWidth, currentHeight, qualitySteps); + // Prefer the smaller of PNG and JPEG at the configured quality; lower JPEG quality only if neither fits. + const preferred = jpeg && jpeg.encodedSize < png.encodedSize ? [jpeg, png] : [png, jpeg]; + for (const candidate of [...preferred, ...lowerQualityJpegs]) { + if (candidate && candidate.encodedSize < opts.maxBytes) { return { data: candidate.data, mimeType: candidate.mimeType, diff --git a/packages/coding-agent/test/image-processing.test.ts b/packages/coding-agent/test/image-processing.test.ts index 1bf3ea53e..0d112a3a7 100644 --- a/packages/coding-agent/test/image-processing.test.ts +++ b/packages/coding-agent/test/image-processing.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it } from "vitest"; import { convertToPng, loadPngTranscoder } from "../src/utils/image-convert.ts"; import { formatDimensionNote, resizeImage } from "../src/utils/image-resize.ts"; +import { resizeImageInProcess } from "../src/utils/image-resize-core.ts"; +import { loadPhoton } from "../src/utils/photon.ts"; // Small 2x2 red PNG image (base64) - generated with ImageMagick const TINY_PNG = @@ -25,6 +27,31 @@ const MEDIUM_PNG_100x100 = const LARGE_PNG_200x200 = "iVBORw0KGgoAAAANSUhEUgAAAMgAAADIEAIAAAByquWKAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGYktHRP///////wlY99wAAAAHdElNRQfqAQ4AMzqU03BFAAAEEElEQVR42u3dvWqUURSF4XXMRAyIhSAWQvwnWtjaWusFx9bW1kKD/wELESxEiBjDjJdwysOC57mCXW32+zGcGbtdACpsdi+T8Xz1GABzm91xMl6sHgNgblzcT/Y+rh4DYG6cv0/2H64eA2BOEgI1fHQHaoyzbXIwVo8BMCcJgRqSEKgxft9Lrn5aPQbA3Ph1klw7Wj0GwJwkBGpYWECN8fMiub63egyAORcWUMPCAmqMH3eTG59XjwEwN76/S24+Wj0GwJwkBGpYWECN8e1fcmuzegyAORcWUMPCAmqM09vJ4dfVYwDMjS9vkzuPV48BMCcJgRqeSAZqjA/nyYP91WMAzElCoIYkBGqMk8Pk6HT1GABzm+3L5JIkBApIQqCGj+5AjfHmb/Lk8uoxAOYkIVBDEgI1LCygxmZ7nFyShEABFxZQw8ICaozXf5KnV1aPATDnwgJqWFhADQsLqOFnDUANFxZQw8ICaoxXZ8mzg9VjAMy5sIAaFhZQw3tYQA1/QgHUkIRADUkI1JCEQA1JCNSQhEANFxZQwzcsoIYkBGpIQqCGJARqSEKghiQEalhYQA1/QgHUcGEBNSwsoIYkBGq4sIAaFhZQw8ICaviGBdRwYQE1LCyghiQEariwgBoWFlDDe1hADS+OAjUkIVBDEgI1JCFQQxICNSQhUMOFBdTwDQuoIQmBGpIQqCEJgRqSEKghCYEaFhZQw4ujQA0XFlDDwgJqSEKghgsLqGFhATUsLKCGb1hADRcWUMPCAmpIQqCGCwuoYWEBNbyHBdTw4ihQQxICNSQhUEMSAjUkIVBDEgI1XFhADd+wgBqSEKghCYEakhCoIQmBGpIQqGFhATW8OArUcGEBNSwsoIYkBGq4sIAaFhZQw8ICaviGBdRwYQE1LCyghiQEariwgBoWFlDDe1hADS+OAjUkIVBDEgI1JCFQQxICNSQhUMOFBdTwDQuoIQmBGpIQqCEJgRqSEKghCYEa49lB8ups9RgAc14cBWpIQqCGhQXUkIRADRcWUMPCAmqMp1eS139WjwEw5xsWUEMSAjUsLKCGJARquLCAGhYWUGM8uZy8+bt6DIA5L44CNSQhUMMTyUCNcXSanByuHgNgThICNSQhUGM82E8+nK8eA2DOzxqAGpIQqDHuPE6+vF09BsDcOPyanN5ePQbAnCQEavgdFlBj3Nok3/6tHgNgzoujQA1JCNQYNx8l39+tHgNgbtz4nPy4u3oMgDlJCNSwsIAa4/pe8vNi9RgAc37WANSQhECNce0o+XWyegyAuXH1U/L73uoxAOYkIVDDwgJqjIORnG1XjwEw508ogBqSEKgx9h8m5+9XjwEwN/Y+Jhf3V48BMCcJgRpjPE+2x6vHAJgbSbLbrR4DYO4/GqiSgXN+ksgAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjYtMDEtMTRUMDA6NTE6NTcrMDA6MDDpysx4AAAAJXRFWHRkYXRlOm1vZGlmeQAyMDI2LTAxLTE0VDAwOjUxOjU3KzAwOjAwmJd0xAAAACh0RVh0ZGF0ZTp0aW1lc3RhbXAAMjAyNi0wMS0xNFQwMDo1MTo1NyswMDowMM+CVRsAAAAASUVORK5CYII="; +/** Photo-like JPEG: smooth gradients with light deterministic noise, so PNG compresses it poorly. */ +async function photoLikeJpeg(width: number, height: number): Promise { + const photon = await loadPhoton(); + if (!photon) throw new Error("Photon not available"); + const pixels = new Uint8Array(width * height * 4); + let seed = 1; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + const noise = seed % 6; + const i = (y * width + x) * 4; + pixels[i] = ((x / width) * 200 + noise) | 0; + pixels[i + 1] = ((y / height) * 200 + noise) | 0; + pixels[i + 2] = (128 + Math.sin(x / 30) * 60 + noise) | 0; + pixels[i + 3] = 255; + } + } + const image = new photon.PhotonImage(pixels, width, height); + try { + return image.get_bytes_jpeg(85); + } finally { + image.free(); + } +} + function imageBytes(base64Data: string): Uint8Array { return Buffer.from(base64Data, "base64"); } @@ -166,6 +193,18 @@ describe("resizeImage", () => { expect(result).toBeNull(); }); + // Regression test for #11551: PNG was returned whenever it fit, even when JPEG was much smaller. + it("should pick JPEG over a larger PNG when resizing a photo", async () => { + const input = await photoLikeJpeg(1200, 800); + const result = await resizeImageInProcess(input, "image/jpeg", { maxWidth: 1000, maxHeight: 1000 }); + + expect(result).not.toBeNull(); + expect(result!.wasResized).toBe(true); + expect(result!.mimeType).toBe("image/jpeg"); + expect(result!.width).toBe(1000); + expect(result!.data.length).toBeLessThan(Math.ceil(input.byteLength / 3) * 4 * 2); + }); + it("should handle JPEG input", async () => { const result = await resizeImage(imageBytes(TINY_JPEG), "image/jpeg", { maxWidth: 100,