mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Large screenshots were being treated like oversized text payloads, which pushed readable images through an aggressive resize/compression path before the model saw them. The image path now preserves images that already fit API byte and dimension limits, prefers lossless or high-quality compression when only bytes are too large, and only downscales by vision pixel budget when the image would exceed the Read token budget. Constraint: Fixes pasted/read image failures reported in #615 and #677 while preserving screenshot readability requested in #663 Rejected: Always resize to 2048px | would unnecessarily degrade screenshots that already fit provider limits Rejected: Continue estimating image budget from base64 text length | image blocks are billed/read as vision pixels, not text payload Confidence: high Scope-risk: moderate Directive: Do not lower fallback JPEG quality or long-edge limits without revalidating real screenshot OCR/vision tasks Tested: bun test src/utils/__tests__/imageResizer.test.ts src/server/__tests__/conversation-attachments.test.ts Tested: bun run check:server Tested: bun run check:coverage (changed-line coverage 95.25%; adapters/desktop coverage lanes failed from existing non-target issues) Tested: GPT-5.5 Read smoke on 1394x4404, 4096x2304, and 7900x900 images Not-tested: Real resize/re-encode branch in this source worktree because optional sharp/native image processor is not installed locally Related: #615 Related: #677 Related: #663
438 lines
14 KiB
TypeScript
438 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
import { rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
|
|
type ProcessorMode = 'metadata' | 'throw'
|
|
|
|
let processorMode: ProcessorMode = 'metadata'
|
|
let resizeCalls: Array<{ width: number; height: number }> = []
|
|
let jpegCalls: Array<{ quality?: number; resized: boolean }> = []
|
|
let pngCalls: Array<{ compressionLevel?: number; palette?: boolean }> = []
|
|
let webpCalls: Array<{ quality?: number; lossless?: boolean }> = []
|
|
|
|
let originalPngDimensions: { width?: number; height?: number; format?: string } =
|
|
{ width: 1394, height: 4404, format: 'png' }
|
|
let outputForOperations: (operations: Operation[]) => Buffer = () =>
|
|
Buffer.from('resized-image')
|
|
|
|
type Operation =
|
|
| { type: 'resize'; width: number; height: number }
|
|
| { type: 'jpeg'; quality?: number }
|
|
| { type: 'png'; compressionLevel?: number; palette?: boolean }
|
|
| { type: 'webp'; quality?: number; lossless?: boolean }
|
|
|
|
mock.module('../../tools/FileReadTool/imageProcessor.js', () => ({
|
|
getImageProcessor: async () => {
|
|
if (processorMode === 'throw') {
|
|
throw new Error('image processor unavailable')
|
|
}
|
|
|
|
return (_input: Buffer) => {
|
|
const operations: Operation[] = []
|
|
const instance = {
|
|
metadata: async () => originalPngDimensions,
|
|
resize: (width: number, height: number) => {
|
|
resizeCalls.push({ width, height })
|
|
operations.push({ type: 'resize', width, height })
|
|
return instance
|
|
},
|
|
jpeg: (options?: { quality?: number }) => {
|
|
jpegCalls.push({
|
|
quality: options?.quality,
|
|
resized: operations.some(op => op.type === 'resize'),
|
|
})
|
|
operations.push({ type: 'jpeg', quality: options?.quality })
|
|
return instance
|
|
},
|
|
png: (options?: { compressionLevel?: number; palette?: boolean }) => {
|
|
pngCalls.push(options ?? {})
|
|
operations.push({
|
|
type: 'png',
|
|
compressionLevel: options?.compressionLevel,
|
|
palette: options?.palette,
|
|
})
|
|
return instance
|
|
},
|
|
webp: (options?: { quality?: number; lossless?: boolean }) => {
|
|
webpCalls.push(options ?? {})
|
|
operations.push({
|
|
type: 'webp',
|
|
quality: options?.quality,
|
|
lossless: options?.lossless,
|
|
})
|
|
return instance
|
|
},
|
|
toBuffer: async () => outputForOperations(operations),
|
|
}
|
|
return instance
|
|
}
|
|
},
|
|
}))
|
|
|
|
const {
|
|
ImageResizeError,
|
|
downsampleImageBufferToVisionTokenBudget,
|
|
maybeResizeAndDownsampleImageBuffer,
|
|
} = await import('../imageResizer.js')
|
|
const { readImageWithTokenBudget } = await import(
|
|
'../../tools/FileReadTool/FileReadTool.js'
|
|
)
|
|
|
|
function makePngHeader(width: number, height: number): Buffer {
|
|
const buffer = Buffer.alloc(32)
|
|
buffer[0] = 0x89
|
|
buffer[1] = 0x50
|
|
buffer[2] = 0x4e
|
|
buffer[3] = 0x47
|
|
buffer.writeUInt32BE(width, 16)
|
|
buffer.writeUInt32BE(height, 20)
|
|
return buffer
|
|
}
|
|
|
|
beforeEach(() => {
|
|
processorMode = 'metadata'
|
|
resizeCalls = []
|
|
jpegCalls = []
|
|
pngCalls = []
|
|
webpCalls = []
|
|
originalPngDimensions = { width: 1394, height: 4404, format: 'png' }
|
|
outputForOperations = () => Buffer.from('resized-image')
|
|
})
|
|
|
|
describe('maybeResizeAndDownsampleImageBuffer', () => {
|
|
test('passes through a tall screenshot when bytes are already within API limits', async () => {
|
|
const imageBuffer = Buffer.alloc(1024, 1)
|
|
|
|
const result = await maybeResizeAndDownsampleImageBuffer(
|
|
imageBuffer,
|
|
imageBuffer.length,
|
|
'png',
|
|
)
|
|
|
|
expect(result.buffer).toBe(imageBuffer)
|
|
expect(result.mediaType).toBe('png')
|
|
expect(resizeCalls).toEqual([])
|
|
expect(result.dimensions).toEqual({
|
|
originalWidth: 1394,
|
|
originalHeight: 4404,
|
|
displayWidth: 1394,
|
|
displayHeight: 4404,
|
|
})
|
|
})
|
|
|
|
test('falls back to the original tall screenshot if local image processing is unavailable', async () => {
|
|
processorMode = 'throw'
|
|
const imageBuffer = makePngHeader(1394, 4404)
|
|
|
|
const result = await maybeResizeAndDownsampleImageBuffer(
|
|
imageBuffer,
|
|
imageBuffer.length,
|
|
'png',
|
|
)
|
|
|
|
expect(result.buffer).toBe(imageBuffer)
|
|
expect(result.mediaType).toBe('png')
|
|
})
|
|
|
|
test('tries lossless webp compression before lossy jpeg for oversized screenshots', async () => {
|
|
originalPngDimensions = { width: 4096, height: 2304, format: 'png' }
|
|
outputForOperations = operations => {
|
|
if (operations.some(op => op.type === 'webp')) {
|
|
return Buffer.alloc(1024, 3)
|
|
}
|
|
return Buffer.alloc(4 * 1024 * 1024, 2)
|
|
}
|
|
const imageBuffer = Buffer.alloc(4 * 1024 * 1024, 1)
|
|
|
|
const result = await maybeResizeAndDownsampleImageBuffer(
|
|
imageBuffer,
|
|
imageBuffer.length,
|
|
'png',
|
|
)
|
|
|
|
expect(result.mediaType).toBe('webp')
|
|
expect(result.buffer.length).toBe(1024)
|
|
expect(resizeCalls).toEqual([])
|
|
expect(webpCalls).toEqual([{ lossless: true }])
|
|
expect(jpegCalls).toEqual([])
|
|
expect(result.dimensions).toEqual({
|
|
originalWidth: 4096,
|
|
originalHeight: 2304,
|
|
displayWidth: 4096,
|
|
displayHeight: 2304,
|
|
})
|
|
})
|
|
|
|
test('downsamples to a codex-sized long edge before lowering jpeg below readable quality', async () => {
|
|
originalPngDimensions = { width: 5000, height: 3000, format: 'png' }
|
|
outputForOperations = operations => {
|
|
const resizeOp = operations.find(
|
|
(op): op is Extract<Operation, { type: 'resize' }> =>
|
|
op.type === 'resize',
|
|
)
|
|
const jpegOp = operations.find(
|
|
(op): op is Extract<Operation, { type: 'jpeg' }> =>
|
|
op.type === 'jpeg',
|
|
)
|
|
if (resizeOp?.width === 2048 && resizeOp.height === 1229) {
|
|
if (jpegOp?.quality === 85) {
|
|
return Buffer.alloc(1024, 4)
|
|
}
|
|
}
|
|
return Buffer.alloc(6 * 1024 * 1024, 5)
|
|
}
|
|
const imageBuffer = Buffer.alloc(6 * 1024 * 1024, 1)
|
|
|
|
const result = await maybeResizeAndDownsampleImageBuffer(
|
|
imageBuffer,
|
|
imageBuffer.length,
|
|
'png',
|
|
)
|
|
|
|
expect(result.mediaType).toBe('jpeg')
|
|
expect(result.buffer.length).toBe(1024)
|
|
expect(resizeCalls).toContainEqual({ width: 2048, height: 1229 })
|
|
expect(jpegCalls.some(call => (call.quality ?? 100) < 75)).toBe(false)
|
|
expect(result.dimensions).toEqual({
|
|
originalWidth: 5000,
|
|
originalHeight: 3000,
|
|
displayWidth: 2048,
|
|
displayHeight: 1229,
|
|
})
|
|
})
|
|
|
|
test('resizes to the hard dimension cap when only height exceeds the API limit', async () => {
|
|
originalPngDimensions = { width: 1000, height: 9000, format: 'png' }
|
|
outputForOperations = operations => {
|
|
const resizeOp = operations.find(
|
|
(op): op is Extract<Operation, { type: 'resize' }> =>
|
|
op.type === 'resize',
|
|
)
|
|
const jpegOp = operations.find(
|
|
(op): op is Extract<Operation, { type: 'jpeg' }> =>
|
|
op.type === 'jpeg',
|
|
)
|
|
if (
|
|
resizeOp?.width === 889 &&
|
|
resizeOp.height === 8000 &&
|
|
jpegOp?.quality === 85
|
|
) {
|
|
return Buffer.alloc(1024, 6)
|
|
}
|
|
return Buffer.alloc(6 * 1024 * 1024, 5)
|
|
}
|
|
const imageBuffer = Buffer.alloc(200_000, 1)
|
|
|
|
const result = await maybeResizeAndDownsampleImageBuffer(
|
|
imageBuffer,
|
|
imageBuffer.length,
|
|
'png',
|
|
)
|
|
|
|
expect(result.mediaType).toBe('jpeg')
|
|
expect(resizeCalls).toContainEqual({ width: 889, height: 8000 })
|
|
expect(result.dimensions).toEqual({
|
|
originalWidth: 1000,
|
|
originalHeight: 9000,
|
|
displayWidth: 889,
|
|
displayHeight: 8000,
|
|
})
|
|
})
|
|
|
|
test('uses readable fallback dimensions before dropping to fallback jpeg quality', async () => {
|
|
originalPngDimensions = { width: 6000, height: 5000, format: 'png' }
|
|
outputForOperations = operations => {
|
|
const resizeOp = operations.find(
|
|
(op): op is Extract<Operation, { type: 'resize' }> =>
|
|
op.type === 'resize',
|
|
)
|
|
const jpegOp = operations.find(
|
|
(op): op is Extract<Operation, { type: 'jpeg' }> =>
|
|
op.type === 'jpeg',
|
|
)
|
|
if (
|
|
resizeOp?.width === 1568 &&
|
|
resizeOp.height === 1307 &&
|
|
jpegOp?.quality === 65
|
|
) {
|
|
return Buffer.alloc(1024, 6)
|
|
}
|
|
return Buffer.alloc(6 * 1024 * 1024, 5)
|
|
}
|
|
const imageBuffer = Buffer.alloc(6 * 1024 * 1024, 1)
|
|
|
|
const result = await maybeResizeAndDownsampleImageBuffer(
|
|
imageBuffer,
|
|
imageBuffer.length,
|
|
'png',
|
|
)
|
|
|
|
expect(result.mediaType).toBe('jpeg')
|
|
expect(resizeCalls).toContainEqual({ width: 2048, height: 1707 })
|
|
expect(resizeCalls).toContainEqual({ width: 1568, height: 1307 })
|
|
expect(jpegCalls.some(call => call.quality === 65)).toBe(true)
|
|
expect(result.dimensions).toEqual({
|
|
originalWidth: 6000,
|
|
originalHeight: 5000,
|
|
displayWidth: 1568,
|
|
displayHeight: 1307,
|
|
})
|
|
})
|
|
|
|
test('throws instead of over-compressing when readable fallback cannot fit', async () => {
|
|
originalPngDimensions = { width: 6000, height: 5000, format: 'png' }
|
|
outputForOperations = () => Buffer.alloc(6 * 1024 * 1024, 5)
|
|
const imageBuffer = Buffer.alloc(6 * 1024 * 1024, 1)
|
|
|
|
expect(
|
|
maybeResizeAndDownsampleImageBuffer(
|
|
imageBuffer,
|
|
imageBuffer.length,
|
|
'png',
|
|
),
|
|
).rejects.toBeInstanceOf(ImageResizeError)
|
|
})
|
|
|
|
test('tries lossless webp for oversized webp screenshots before jpeg conversion', async () => {
|
|
originalPngDimensions = { width: 4096, height: 2304, format: 'webp' }
|
|
outputForOperations = operations => {
|
|
if (operations.some(op => op.type === 'webp')) {
|
|
return Buffer.alloc(1024, 3)
|
|
}
|
|
return Buffer.alloc(4 * 1024 * 1024, 2)
|
|
}
|
|
const imageBuffer = Buffer.alloc(4 * 1024 * 1024, 1)
|
|
|
|
const result = await maybeResizeAndDownsampleImageBuffer(
|
|
imageBuffer,
|
|
imageBuffer.length,
|
|
'webp',
|
|
)
|
|
|
|
expect(result.mediaType).toBe('webp')
|
|
expect(webpCalls).toEqual([{ lossless: true }])
|
|
expect(jpegCalls).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('readImageWithTokenBudget', () => {
|
|
test('does not recompress readable screenshots just because their base64 text would exceed maxTokens', async () => {
|
|
originalPngDimensions = { width: 1920, height: 1080, format: 'png' }
|
|
outputForOperations = () => Buffer.from('compressed-image')
|
|
const imageBuffer = Buffer.alloc(200_000, 1)
|
|
const imagePath = join(tmpdir(), `cc-haha-image-budget-${Date.now()}.png`)
|
|
await writeFile(imagePath, imageBuffer)
|
|
|
|
try {
|
|
const result = await readImageWithTokenBudget(imagePath, 25_000)
|
|
|
|
expect(result.file.base64).toBe(imageBuffer.toString('base64'))
|
|
expect(result.file.type).toBe('image/png')
|
|
expect(jpegCalls).toEqual([])
|
|
expect(resizeCalls).toEqual([])
|
|
} finally {
|
|
await rm(imagePath, { force: true })
|
|
}
|
|
})
|
|
|
|
test('downsamples over-budget vision images by pixel budget instead of base64 text budget', async () => {
|
|
originalPngDimensions = { width: 6000, height: 4000, format: 'png' }
|
|
outputForOperations = operations => {
|
|
const resizeOp = operations.find(
|
|
(op): op is Extract<Operation, { type: 'resize' }> =>
|
|
op.type === 'resize',
|
|
)
|
|
const jpegOp = operations.find(
|
|
(op): op is Extract<Operation, { type: 'jpeg' }> =>
|
|
op.type === 'jpeg',
|
|
)
|
|
if (
|
|
resizeOp?.width === 3674 &&
|
|
resizeOp.height === 2449 &&
|
|
jpegOp?.quality === 85
|
|
) {
|
|
return Buffer.alloc(1024, 7)
|
|
}
|
|
return Buffer.alloc(6 * 1024 * 1024, 8)
|
|
}
|
|
const imageBuffer = Buffer.alloc(200_000, 1)
|
|
const imagePath = join(
|
|
tmpdir(),
|
|
`cc-haha-image-vision-budget-${Date.now()}.png`,
|
|
)
|
|
await writeFile(imagePath, imageBuffer)
|
|
|
|
try {
|
|
const result = await readImageWithTokenBudget(imagePath, 12_000)
|
|
|
|
expect(result.file.type).toBe('image/jpeg')
|
|
expect(Buffer.from(result.file.base64, 'base64').length).toBe(1024)
|
|
expect(resizeCalls).toContainEqual({ width: 3674, height: 2449 })
|
|
expect(jpegCalls.some(call => (call.quality ?? 100) < 75)).toBe(false)
|
|
expect(result.file.dimensions).toEqual({
|
|
originalWidth: 6000,
|
|
originalHeight: 4000,
|
|
displayWidth: 3674,
|
|
displayHeight: 2449,
|
|
})
|
|
} finally {
|
|
await rm(imagePath, { force: true })
|
|
}
|
|
})
|
|
|
|
test('uses a readable fallback when the vision-budget resize cannot fit the byte cap', async () => {
|
|
originalPngDimensions = { width: 6000, height: 4000, format: 'png' }
|
|
outputForOperations = operations => {
|
|
const resizeOp = operations.find(
|
|
(op): op is Extract<Operation, { type: 'resize' }> =>
|
|
op.type === 'resize',
|
|
)
|
|
const jpegOp = operations.find(
|
|
(op): op is Extract<Operation, { type: 'jpeg' }> =>
|
|
op.type === 'jpeg',
|
|
)
|
|
if (
|
|
resizeOp?.width === 1568 &&
|
|
resizeOp.height === 1045 &&
|
|
jpegOp?.quality === 65
|
|
) {
|
|
return Buffer.alloc(1024, 7)
|
|
}
|
|
return Buffer.alloc(6 * 1024 * 1024, 8)
|
|
}
|
|
const imageBuffer = Buffer.alloc(200_000, 1)
|
|
|
|
const result = await downsampleImageBufferToVisionTokenBudget(
|
|
imageBuffer,
|
|
imageBuffer.length,
|
|
'png',
|
|
12_000,
|
|
)
|
|
|
|
expect(result.mediaType).toBe('jpeg')
|
|
expect(resizeCalls).toContainEqual({ width: 3674, height: 2449 })
|
|
expect(resizeCalls).toContainEqual({ width: 1568, height: 1045 })
|
|
expect(result.dimensions).toEqual({
|
|
originalWidth: 6000,
|
|
originalHeight: 4000,
|
|
displayWidth: 1568,
|
|
displayHeight: 1045,
|
|
})
|
|
})
|
|
|
|
test('reports an image resize error when token-budget dimensions are unavailable', async () => {
|
|
originalPngDimensions = { format: 'png' }
|
|
const imageBuffer = Buffer.alloc(200_000, 1)
|
|
|
|
expect(
|
|
downsampleImageBufferToVisionTokenBudget(
|
|
imageBuffer,
|
|
imageBuffer.length,
|
|
'png',
|
|
12_000,
|
|
),
|
|
).rejects.toBeInstanceOf(ImageResizeError)
|
|
})
|
|
})
|