Repository navigation
fix(server): Claude images pasted mid-turn no longer break later image reads #13869
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
56b4087
110043c
3050c9a
67eafeb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import { readImageDimensions } from "@t3tools/shared/imageDimensions"; | ||
| import jpeg from "jpeg-js"; | ||
| import { PNG } from "pngjs"; | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
|
|
||
| import { fitClaudeImage } from "./claudeImageSize.ts"; | ||
|
|
||
| function png(width: number, height: number, alpha: number, colorType: 0 | 6 = 6) { | ||
| const image = new PNG({ width, height }); | ||
| image.data.fill(alpha); | ||
| return PNG.sync.write(image, { colorType }); | ||
| } | ||
|
|
||
| function fitted(mimeType: string, bytes: Uint8Array) { | ||
| const result = fitClaudeImage(mimeType, bytes); | ||
| if (!result) throw new Error("expected a fitted image"); | ||
| return Buffer.from(result); | ||
| } | ||
|
|
||
| describe("fitClaudeImage", () => { | ||
| it("leaves an image within 2000px untouched", () => { | ||
| const bytes = png(2000, 1200, 255); | ||
| expect(fitClaudeImage("image/png", bytes)).toBe(bytes); | ||
| }); | ||
|
|
||
| it("scales a tall phone screenshot to 2000px and keeps its transparency", () => { | ||
| const decoded = PNG.sync.read(fitted("image/png", png(1080, 2424, 128))); | ||
| expect({ width: decoded.width, height: decoded.height }).toEqual({ width: 891, height: 2000 }); | ||
| expect(decoded.data[3]).toBe(128); | ||
| }); | ||
|
|
||
| it("does not darken colors next to transparent pixels", () => { | ||
| const image = new PNG({ width: 4000, height: 10 }); | ||
| // Alternate opaque white and fully transparent black columns. | ||
| for (let offset = 0; offset < image.data.length; offset += 4) { | ||
| image.data.fill((offset / 4) % 2 === 0 ? 255 : 0, offset, offset + 4); | ||
| } | ||
| const decoded = PNG.sync.read(fitted("image/png", PNG.sync.write(image))); | ||
| expect([...decoded.data.subarray(0, 4)]).toEqual([255, 255, 255, 128]); | ||
| }); | ||
|
|
||
| it("refuses a PNG too large to decode safely", () => { | ||
| const header = Buffer.alloc(24); | ||
| header.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13], 0); | ||
| header.write("IHDR", 12, "ascii"); | ||
| header.writeUInt32BE(20_000, 16); | ||
| header.writeUInt32BE(20_000, 20); | ||
| expect(fitClaudeImage("image/png", header)).toBeNull(); | ||
| }); | ||
|
|
||
| it("refuses a JPEG that exceeds the decoder's memory budget", () => { | ||
| // SOI, then a 4:4:4 SOF0 frame header for 8000x6000 (48MP), then EOI. | ||
| const header = Uint8Array.from([ | ||
| 0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x17, 0x70, 0x1f, 0x40, 0x03, 0x01, 0x11, 0x00, | ||
| 0x02, 0x11, 0x00, 0x03, 0x11, 0x00, 0xff, 0xd9, | ||
| ]); | ||
| expect(fitClaudeImage("image/jpeg", header)).toBeNull(); | ||
| }); | ||
|
|
||
| it("keeps a grayscale PNG grayscale", () => { | ||
| const decoded = PNG.sync.read(fitted("image/png", png(2400, 100, 90, 0))); | ||
| expect({ width: decoded.width, colorType: decoded.colorType }).toEqual({ | ||
| width: 2000, | ||
| colorType: 0, | ||
| }); | ||
| }); | ||
|
|
||
| it("scales a wide JPEG and keeps its EXIF orientation", () => { | ||
| const { data } = jpeg.encode( | ||
| { | ||
| width: 3000, | ||
| height: 1000, | ||
| data: new Uint8Array(3000 * 1000 * 4).fill(200), | ||
| // EXIF with orientation 6 (rotate 90° clockwise). | ||
| exifBuffer: Buffer.from([ | ||
| 0x45, 0x78, 0x69, 0x66, 0x00, 0x00, 0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08, 0x00, | ||
| 0x01, 0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, | ||
| 0x00, 0x00, | ||
| ]), | ||
| } as Parameters<typeof jpeg.encode>[0], | ||
| 90, | ||
| ); | ||
| // Orientation 6 swaps the reported sides. | ||
| expect(readImageDimensions(fitted("image/jpeg", data))).toEqual({ | ||
| width: 667, | ||
| height: 2000, | ||
| }); | ||
| }); | ||
|
|
||
| it("passes an oversized GIF through", () => { | ||
| // GIF89a header, 2000x2001. | ||
| const gif = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0xd0, 0x07, 0xd1, 0x07]); | ||
| expect(fitClaudeImage("image/gif", gif)).toBe(gif); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| import { readImageDimensions } from "@t3tools/shared/imageDimensions"; | ||
| import jpeg from "jpeg-js"; | ||
| import { PNG } from "pngjs"; | ||
|
|
||
| /** | ||
| * Anthropic rejects every image over 2000px on a side once a request holds | ||
| * more than 20 images. Claude Code scales the images in a turn's opening | ||
| * message, but stores ones sent mid-turn as they are, so one oversized paste | ||
| * breaks every later image read in the session. | ||
| */ | ||
| const CLAUDE_MAX_IMAGE_EDGE = 2000; | ||
|
|
||
| /** | ||
| * A highly compressible PNG fits the attachment byte cap at any size, and | ||
| * decoding expands it to 4 bytes per pixel, so past this bound a PNG or JPEG | ||
| * is not decoded. 50MP still covers an 8000x6000 photo. | ||
| */ | ||
| const MAX_DECODED_PIXELS = 50_000_000; | ||
|
|
||
| /** | ||
| * Scales a PNG or JPEG down to fit CLAUDE_MAX_IMAGE_EDGE. Images already | ||
| * within the limit come back untouched, so only oversized ones pay for a | ||
| * decode. Returns null for a PNG or JPEG too large to decode safely, since | ||
| * sending it unscaled would break the thread. | ||
| * | ||
| * ponytail: decodes on the event loop, about 150ms for a phone screenshot, | ||
| * 400ms for a Retina screenshot, and 0.7s for a 12MP photo. Only steered | ||
| * images pay it. Move it to a worker if that stall shows up. | ||
| */ | ||
| export function fitClaudeImage(mimeType: string, bytes: Uint8Array): Uint8Array | null { | ||
| const dimensions = readImageDimensions(bytes); | ||
| if (!dimensions || Math.max(dimensions.width, dimensions.height) <= CLAUDE_MAX_IMAGE_EDGE) { | ||
| return bytes; | ||
| } | ||
| const decodable = dimensions.width * dimensions.height <= MAX_DECODED_PIXELS; | ||
| if (mimeType === "image/png") { | ||
| if (!decodable) return null; | ||
| const image = PNG.sync.read(Buffer.from(bytes)); | ||
| const { width, height, data } = downscale(image); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High A steer containing multiple accepted oversized images can block the Node event loop for a prolonged period and exhaust memory: each 50 MP PNG passes 🤖 Copy this AI Prompt to have your agent fix this: |
||
| const scaled = new PNG({ width, height }); | ||
| scaled.data = Buffer.from(data.buffer); | ||
| // Keep the source's channels; always writing RGBA can make a grayscale | ||
| // or palette PNG several times larger than the original. | ||
| const colorType = image.alpha ? (image.color ? 6 : 4) : image.color ? 2 : 0; | ||
| return PNG.sync.write(scaled, { colorType }); | ||
| } | ||
| if (mimeType === "image/jpeg") { | ||
| const image = decodable ? decodeJpeg(bytes) : null; | ||
| if (!image) return null; | ||
| // The spread keeps the decoder's `exifBuffer`, so the EXIF orientation of | ||
| // a phone photo survives the re-encode. | ||
| return jpeg.encode({ ...image, ...downscale(image) }, 90).data; | ||
| } | ||
| // ponytail: GIF and WebP pass through; no pure-JS codec for them here. Add | ||
| // one if oversized GIF or WebP attachments show up in practice. | ||
| return bytes; | ||
|
Gigioxx marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| /** Decodes a JPEG, or returns null when it would exceed jpeg-js's 512MB budget. */ | ||
| function decodeJpeg(bytes: Uint8Array) { | ||
| try { | ||
| return jpeg.decode(bytes, { useTArray: true }); | ||
| } catch (error) { | ||
| // 4:4:4 JPEGs need about 12 bytes per pixel, so some pass MAX_DECODED_PIXELS | ||
| // and still hit the decoder's memory cap. | ||
| if (error instanceof Error && error.message.startsWith("maxMemoryUsageInMB")) return null; | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| /** Box-filter downscale of RGBA pixels to fit CLAUDE_MAX_IMAGE_EDGE. */ | ||
| function downscale(image: { width: number; height: number; data: Uint8Array }) { | ||
| const scale = CLAUDE_MAX_IMAGE_EDGE / Math.max(image.width, image.height); | ||
| const width = Math.max(1, Math.round(image.width * scale)); | ||
| const height = Math.max(1, Math.round(image.height * scale)); | ||
| const data = new Uint8Array(width * height * 4); | ||
| for (let y = 0; y < height; y++) { | ||
| const top = Math.floor((y * image.height) / height); | ||
| const bottom = Math.max(top + 1, Math.floor(((y + 1) * image.height) / height)); | ||
| for (let x = 0; x < width; x++) { | ||
| const left = Math.floor((x * image.width) / width); | ||
| const right = Math.max(left + 1, Math.floor(((x + 1) * image.width) / width)); | ||
| let red = 0; | ||
|
Gigioxx marked this conversation as resolved.
Resolved
|
||
| let green = 0; | ||
| let blue = 0; | ||
| let alpha = 0; | ||
| for (let sourceY = top; sourceY < bottom; sourceY++) { | ||
| for (let sourceX = left; sourceX < right; sourceX++) { | ||
| const offset = (sourceY * image.width + sourceX) * 4; | ||
| // Weighting color by alpha keeps transparent pixels from darkening edges. | ||
| const pixelAlpha = image.data[offset + 3] ?? 0; | ||
| red += (image.data[offset] ?? 0) * pixelAlpha; | ||
| green += (image.data[offset + 1] ?? 0) * pixelAlpha; | ||
| blue += (image.data[offset + 2] ?? 0) * pixelAlpha; | ||
| alpha += pixelAlpha; | ||
| } | ||
| } | ||
| const target = (y * width + x) * 4; | ||
| if (alpha > 0) { | ||
| data[target] = Math.round(red / alpha); | ||
| data[target + 1] = Math.round(green / alpha); | ||
| data[target + 2] = Math.round(blue / alpha); | ||
| } | ||
| data[target + 3] = Math.round(alpha / ((bottom - top) * (right - left))); | ||
| } | ||
| } | ||
| return { width, height, data }; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.