diff --git a/bun.lock b/bun.lock index c8a98fca6e3..41c8e8ffdbf 100644 --- a/bun.lock +++ b/bun.lock @@ -276,6 +276,7 @@ "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.15.1", + "@napi-rs/image": "^1.10.1", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", @@ -1097,7 +1098,35 @@ "@motionone/utils": ["@motionone/utils@10.18.0", "", { "dependencies": { "@motionone/types": "^10.17.1", "hey-listen": "^1.0.8", "tslib": "^2.3.1" } }, "sha512-3XVF7sgyTSI2KWvTf6uLlBJ5iAgRgmvp3bpuOiQJvInd4nZ19ET8lX5unn30SlmRH7hXbBbH+Gxd0m0klJ3Xtw=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], + "@napi-rs/image": ["@napi-rs/image@1.12.0", "", { "optionalDependencies": { "@napi-rs/image-android-arm64": "1.12.0", "@napi-rs/image-darwin-arm64": "1.12.0", "@napi-rs/image-darwin-x64": "1.12.0", "@napi-rs/image-freebsd-x64": "1.12.0", "@napi-rs/image-linux-arm-gnueabihf": "1.12.0", "@napi-rs/image-linux-arm64-gnu": "1.12.0", "@napi-rs/image-linux-arm64-musl": "1.12.0", "@napi-rs/image-linux-x64-gnu": "1.12.0", "@napi-rs/image-linux-x64-musl": "1.12.0", "@napi-rs/image-wasm32-wasi": "1.12.0", "@napi-rs/image-win32-arm64-msvc": "1.12.0", "@napi-rs/image-win32-ia32-msvc": "1.12.0", "@napi-rs/image-win32-x64-msvc": "1.12.0" } }, "sha512-hfW9EvlUj4R+RYu5Cgcm9/d/2eMTj3Dhypj0BD+snasawBpYwSfkPZx/6C1Hm2cVCbRT6zovGdSHUXwPLb29dw=="], + + "@napi-rs/image-android-arm64": ["@napi-rs/image-android-arm64@1.12.0", "", { "os": "android", "cpu": "arm64" }, "sha512-MAm8EHmtO47OZYsHgiMuP+nYZOEbNWbHjkoNfRS9wFJiRQ5p/pIlvdeWL9DqkSrjcgHjIJXLcrt94MMF1jXOuw=="], + + "@napi-rs/image-darwin-arm64": ["@napi-rs/image-darwin-arm64@1.12.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NXDXy9qNmNdesKCTWMcKa9QHP74Ut75Lwi4psUzo5e7ptOeK6ACIanVPynnfWGMUXY4pTIXvGooLf5mbn6r7iQ=="], + + "@napi-rs/image-darwin-x64": ["@napi-rs/image-darwin-x64@1.12.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-577Ysv/m60Pmv2rahxf2c6ChfNwGyAs3Pyoopao6YxknXnq+M8/x8PW3f9Gxg9+a/nxxnFIYoMDkD1GeQRecXg=="], + + "@napi-rs/image-freebsd-x64": ["@napi-rs/image-freebsd-x64@1.12.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-WwQvpQ8FCpFjEHuI5nGKl67oPj4Yc5clbNrSy2wBzIcmuuOjfzGFi96MDZU6gQvRYZz+jbGa71+Fk3bMtbp8ew=="], + + "@napi-rs/image-linux-arm-gnueabihf": ["@napi-rs/image-linux-arm-gnueabihf@1.12.0", "", { "os": "linux", "cpu": "arm" }, "sha512-UaIVUREI7q9NpR6mBNyGJ7D8S4vdQ65X2RoUmAv7f89ILvKobyyn8LFx8MqjPU5UpmWQhoU5nATQzKsYF70HoQ=="], + + "@napi-rs/image-linux-arm64-gnu": ["@napi-rs/image-linux-arm64-gnu@1.12.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-QwZRPAJjtRAPtrtvn8slmtcMtAWLd15kRL6BxBaAA/VHR/sOzKxCbXbapzf/nHUJUsO7Gy3wQu0SKr7Wjp5K9w=="], + + "@napi-rs/image-linux-arm64-musl": ["@napi-rs/image-linux-arm64-musl@1.12.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1laZq0DdtuI6DBFkGRB0tSv/STo11qjGKfCVUndt3JbP4nidO4enRDjofBTu5ROFcxqSdIq/t7hYhlS+BeJRnw=="], + + "@napi-rs/image-linux-x64-gnu": ["@napi-rs/image-linux-x64-gnu@1.12.0", "", { "os": "linux", "cpu": "x64" }, "sha512-TaaITRqJXnRip1kV2o+M6dMFyQdKs8zlPHyxZU6sfbjyPfgtkliKXR6kxxug2MVCr7jXuAFdrJRCdfyAeK4MIQ=="], + + "@napi-rs/image-linux-x64-musl": ["@napi-rs/image-linux-x64-musl@1.12.0", "", { "os": "linux", "cpu": "x64" }, "sha512-QdvbOh+lekT+gKpYhINOHd4ruEYQHzEVhNqswZ2V3mWH5tbXS9ypjzzfZ1UVeHNfce3NooZ+XsZVGITX/KyUwQ=="], + + "@napi-rs/image-wasm32-wasi": ["@napi-rs/image-wasm32-wasi@1.12.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.0" }, "cpu": "none" }, "sha512-tlLe2+4RdJfkT8DOGJ7KpUGYxlhvqaYCKuPoyO2x/LPnfF2kSKO9nLIdBhCNw7v61gfxIEpr2ccz01Mw8FeSjA=="], + + "@napi-rs/image-win32-arm64-msvc": ["@napi-rs/image-win32-arm64-msvc@1.12.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-JlyIKiwhMRq0MUGIILaHeqS0Rc2kazdN9t0cjv7DrN5FLwTPmXFQyDsgGEW8oFJ08DKpbn6Dfy/5UogjII+hKg=="], + + "@napi-rs/image-win32-ia32-msvc": ["@napi-rs/image-win32-ia32-msvc@1.12.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-nzg7bUJWkVz05zbsBmu4fZ5wj1KFyBbJwEV4OuV5qdz0IFAl37NEannO0yg0Yi9Amavgqmr5y7OmA2TiandpPA=="], + + "@napi-rs/image-win32-x64-msvc": ["@napi-rs/image-win32-x64-msvc@1.12.0", "", { "os": "win32", "cpu": "x64" }, "sha512-tTS4k9Bep2bJbymuu523sp8MZDdUaTkjORWkiwOHPFULt2e+pgnCCQ6+gXJ7WAWXvdwKn06gfdMsC2DM8k5EtA=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.0", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -4121,6 +4150,10 @@ "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], + "@oxc-minify/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], + + "@oxc-transform/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], + "@pierre/diffs/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], "@pierre/diffs/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ZfWJNm2VMhKkQIKT9qXbs76RRcT0SF/CAvEz0+RkpUDAoDaCx0uFdCGzSRiD9gSlhm6AHkjdieOBJMaO2eC1rQ=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c5e309c1071..60d2b46cfde 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -65,6 +65,7 @@ "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.15.1", + "@napi-rs/image": "^1.10.1", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 18384f65e01..572b2eec55a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -20,6 +20,8 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { Editor } from "@tui/util/editor" import { useExit } from "../../context/exit" import { Clipboard } from "../../util/clipboard" +import { ImageOptimizer } from "@/util/image-optimizer" +import { DialogSelect } from "../../ui/dialog-select" import type { FilePart } from "@opencode-ai/sdk/v2" import { TuiEvent } from "../../event" import { iife } from "@/util/iife" @@ -142,6 +144,16 @@ export function Prompt(props: PromptProps) { const textareaKeybindings = createMemo(() => { const keybinds = keybind.all + // When a dialog is open (e.g., image compression confirmation), don't bind + // return to submit. This prevents the prompt from being submitted when the + // user presses Enter to confirm the dialog action. + if (dialog.stack.length > 0) { + return [ + { name: "return", meta: true, action: "newline" }, + ...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)), + ] satisfies KeyBinding[] + } + return [ { name: "return", action: "submit" }, { name: "return", meta: true, action: "newline" }, @@ -219,11 +231,7 @@ export function Prompt(props: PromptProps) { onSelect: async () => { const content = await Clipboard.read() if (content?.mime.startsWith("image/")) { - await pasteImage({ - filename: "clipboard", - mime: content.mime, - content: content.data, - }) + await handleImagePaste(content.data, content.mime, "clipboard") } }, }, @@ -670,6 +678,55 @@ export function Prompt(props: PromptProps) { ) } + async function handleImagePaste(base64: string, mime: string, filename?: string) { + const buffer = Buffer.from(base64, "base64") + const imageSize = buffer.byteLength + + let content = base64 + let targetMime = mime + + if (ImageOptimizer.needsOptimization(imageSize)) { + const confirmed = await new Promise((resolve) => { + dialog.replace( + () => ( + { + resolve(option.value === "compress") + dialog.clear() + }} + /> + ), + () => resolve(false), + ) + }) + if (!confirmed) return + + try { + const result = await ImageOptimizer.optimize(buffer) + content = result.data + targetMime = result.mime + } catch (error) { + console.error("Image compression failed:", error) + toast.show({ + variant: "error", + message: "Failed to compress image", + }) + return + } + } + + await pasteImage({ + filename, + mime: targetMime, + content, + }) + } + async function pasteImage(file: { filename?: string; content: string; mime: string }) { const currentOffset = input.visualCursor.offset const extmarkStart = currentOffset @@ -805,11 +862,7 @@ export function Prompt(props: PromptProps) { const content = await Clipboard.read() if (content?.mime.startsWith("image/")) { e.preventDefault() - await pasteImage({ - filename: "clipboard", - mime: content.mime, - content: content.data, - }) + await handleImagePaste(content.data, content.mime, "clipboard") return } // If no image, let the default paste behavior continue @@ -905,18 +958,9 @@ export function Prompt(props: PromptProps) { } if (file.type.startsWith("image/")) { event.preventDefault() - const content = await file - .arrayBuffer() - .then((buffer) => Buffer.from(buffer).toString("base64")) - .catch(() => {}) - if (content) { - await pasteImage({ - filename: file.name, - mime: file.type, - content, - }) - return - } + const buffer = await file.arrayBuffer() + await handleImagePaste(Buffer.from(buffer).toString("base64"), file.type, file.name) + return } } catch {} } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fabe3fa5128..d87000d9c5c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -28,6 +28,7 @@ import { LSP } from "../lsp" import { ReadTool } from "../tool/read" import { ListTool } from "../tool/ls" import { FileTime } from "../file/time" +import { ImageOptimizer } from "../util/image-optimizer" import { Flag } from "../flag/flag" import { ulid } from "ulid" import { spawn } from "child_process" @@ -51,6 +52,34 @@ export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000 + /** Compress image if it exceeds size limit, returns original data if compression fails */ + function compressImageIfNeeded( + buffer: Buffer, + mime: string, + data: string, + source: string, + ): Promise<{ mime: string; data: string }> { + if (!ImageOptimizer.needsOptimization(buffer.length)) { + return Promise.resolve({ mime, data }) + } + + log.info(`Compressing ${source} image`, { + originalSize: ImageOptimizer.formatBytes(buffer.length), + }) + + return ImageOptimizer.optimize(buffer) + .then((result) => { + log.info("Compression successful", { + newSize: ImageOptimizer.formatBytes(Buffer.from(result.data, "base64").length), + }) + return { mime: result.mime, data: result.data } + }) + .catch((error) => { + log.error("Compression failed, using original", { error }) + return { mime, data } + }) + } + const state = Instance.state( () => { const data: Record< @@ -683,13 +712,21 @@ export namespace SessionPrompt { if (contentItem.type === "text") { textParts.push(contentItem.text) } else if (contentItem.type === "image") { + const buffer = Buffer.from(contentItem.data, "base64") + const compressed = await compressImageIfNeeded( + buffer, + contentItem.mimeType, + contentItem.data, + "tool-returned", + ) + attachments.push({ id: Identifier.ascending("part"), sessionID: input.sessionID, messageID: input.processor.message.id, type: "file", - mime: contentItem.mimeType, - url: `data:${contentItem.mimeType};base64,${contentItem.data}`, + mime: compressed.mime, + url: `data:${compressed.mime};base64,${compressed.data}`, }) } // Add support for other types if needed @@ -921,6 +958,18 @@ export namespace SessionPrompt { const file = Bun.file(filepath) FileTime.read(input.sessionID, filepath) + const fileBuffer = Buffer.from(await file.bytes()) + + let fileMime = part.mime + let fileData = fileBuffer.toString("base64") + + // Compress images if needed + if (part.mime?.startsWith("image/")) { + const compressed = await compressImageIfNeeded(fileBuffer, part.mime, fileData, "file-provided") + fileMime = compressed.mime + fileData = compressed.data + } + return [ { id: Identifier.ascending("part"), @@ -935,8 +984,8 @@ export namespace SessionPrompt { messageID: info.id, sessionID: input.sessionID, type: "file", - url: `data:${part.mime};base64,` + Buffer.from(await file.bytes()).toString("base64"), - mime: part.mime, + url: `data:${fileMime};base64,${fileData}`, + mime: fileMime, filename: part.filename!, source: part.source, }, @@ -965,6 +1014,32 @@ export namespace SessionPrompt { ] } + // Handle image compression for file parts with data: URLs + if (part.type === "file" && part.mime?.startsWith("image/")) { + const match = part.url.match(/^data:image\/[^;]+;base64,(.+)$/) + if (match) { + const fileMime = part.mime + const buffer = Buffer.from(match[1], "base64") + const compressed = await compressImageIfNeeded( + buffer, + fileMime, + match[1], + "user-provided", + ) + + return [ + { + id: part.id ?? Identifier.ascending("part"), + ...part, + mime: compressed.mime, + url: `data:${compressed.mime};base64,${compressed.data}`, + messageID: info.id, + sessionID: input.sessionID, + }, + ] + } + } + return [ { id: Identifier.ascending("part"), diff --git a/packages/opencode/src/util/image-optimizer.ts b/packages/opencode/src/util/image-optimizer.ts new file mode 100644 index 00000000000..49a4c999649 --- /dev/null +++ b/packages/opencode/src/util/image-optimizer.ts @@ -0,0 +1,89 @@ +import { Transformer, ResizeFilterType, JsColorType, losslessCompressPng } from "@napi-rs/image" + +const ALPHA_COLOR_TYPES = new Set([ + JsColorType.La8, + JsColorType.Rgba8, + JsColorType.La16, + JsColorType.Rgba16, + JsColorType.Rgba32F, +]) + +async function encodeImage(img: Transformer, hasAlpha: boolean, quality: number): Promise { + if (hasAlpha) { + const pngBuffer = await img.png() + return losslessCompressPng(pngBuffer) + } + return img.jpeg(quality) +} + +export namespace ImageOptimizer { + export const SIZE_LIMIT = 5 * 1024 * 1024 // 5MB + + export interface OptimizationResult { + data: string // base64 encoded + mime: string // "image/png" or "image/jpeg" + } + + export function formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + " MB" + if (bytes >= 1024) return (bytes / 1024).toFixed(2) + " KB" + return bytes + " B" + } + + export function needsOptimization(size: number): boolean { + return size > SIZE_LIMIT + } + + /** + * Optimize image to fit under 5MB + * - Preserves PNG for transparent images (with lossless compression) + * - Converts to JPEG for opaque images + * - Reduces dimensions first (bigger savings), then quality for JPEG + */ + export async function optimize(input: Buffer): Promise { + const metadata = await new Transformer(input).metadata() + const hasAlpha = ALPHA_COLOR_TYPES.has(metadata.colorType) + const state = { width: metadata.width, height: metadata.height, quality: 85, triedLowerQuality: false } + + // Iterative optimization: reduce dimensions first, then quality (JPEG only) + for (;;) { + const img = new Transformer(input) + + // Resize from original if dimensions changed (avoids cascading quality loss) + if (state.width !== metadata.width || state.height !== metadata.height) { + img.resize(state.width, state.height, ResizeFilterType.Lanczos3) + } + + const result = await encodeImage(img, hasAlpha, state.quality) + + if (result.length <= SIZE_LIMIT || state.width < 100 || state.height < 100) { + return { + data: result.toString("base64"), + mime: hasAlpha ? "image/png" : "image/jpeg", + } + } + + // First pass at original dimensions - try reducing size + if (state.width === metadata.width && state.height === metadata.height) { + state.width = Math.floor(state.width * 0.8) + state.height = Math.floor(state.height * 0.8) + continue + } + + // JPEG: try quality reduction once before more dimension reduction + if (!hasAlpha && !state.triedLowerQuality && state.quality > 75) { + state.quality = 75 + state.triedLowerQuality = true + continue + } + + // Further dimension reduction needed + state.width = Math.floor(state.width * 0.8) + state.height = Math.floor(state.height * 0.8) + if (!hasAlpha) { + state.quality = 85 + state.triedLowerQuality = false + } + } + } +} diff --git a/packages/opencode/test/fixture/image.ts b/packages/opencode/test/fixture/image.ts new file mode 100644 index 00000000000..f5065c76fc7 --- /dev/null +++ b/packages/opencode/test/fixture/image.ts @@ -0,0 +1,97 @@ +import { Transformer } from "@napi-rs/image" + +/** + * Shared test image generator to avoid creating images repeatedly in tests + * Images are cached after first generation + */ + +interface TestImage { + buffer: Buffer + size: number + width: number + height: number + base64: string +} + +const cache: { large: TestImage | null; small: TestImage | null } = { large: null, small: null } + +/** Create a BMP buffer with pseudo-random pixels for testing */ +function createBmpBuffer(width: number, height: number, seed: number): Buffer { + const rowSize = Math.ceil((width * 3) / 4) * 4 + const pixelDataSize = rowSize * height + const buffer = Buffer.alloc(54 + pixelDataSize) + + // BMP header + buffer.write("BM", 0) + buffer.writeUInt32LE(54 + pixelDataSize, 2) + buffer.writeUInt32LE(54, 10) + buffer.writeUInt32LE(40, 14) + buffer.writeInt32LE(width, 18) + buffer.writeInt32LE(height, 22) + buffer.writeUInt16LE(1, 26) + buffer.writeUInt16LE(24, 28) + buffer.writeUInt32LE(pixelDataSize, 34) + + // Fill with pseudo-random pixels (LCG PRNG for reproducibility) + const prng = { state: seed } + for (let i = 54; i < buffer.length; i++) { + prng.state = (prng.state * 1103515245 + 12345) & 0x7fffffff + buffer[i] = prng.state % 256 + } + + return buffer +} + +/** + * Generate a large PNG image (~6MB) that needs compression + * Cached after first generation to avoid recreating it + */ +export async function getLargeTestImage(): Promise { + if (cache.large) return cache.large + + const width = 4500 + const height = 4500 + const bmpBuffer = createBmpBuffer(width, height, 12345) + const pngBuffer = await new Transformer(bmpBuffer).png() + + cache.large = { + buffer: pngBuffer, + size: pngBuffer.length, + width, + height, + base64: pngBuffer.toString("base64"), + } + + return cache.large +} + +/** + * Generate a small PNG image (~500KB) that doesn't need compression + * Cached after first generation + */ +export async function getSmallTestImage(): Promise { + if (cache.small) return cache.small + + const width = 800 + const height = 600 + const bmpBuffer = createBmpBuffer(width, height, 54321) + const pngBuffer = await new Transformer(bmpBuffer).png() + + cache.small = { + buffer: pngBuffer, + size: pngBuffer.length, + width, + height, + base64: pngBuffer.toString("base64"), + } + + return cache.small +} + +/** + * Reset cached images (useful between test suites) + */ +export function clearImageCache() { + cache.large = null + cache.small = null +} diff --git a/packages/opencode/test/util/image-optimizer.test.ts b/packages/opencode/test/util/image-optimizer.test.ts new file mode 100644 index 00000000000..f4a882dfc9a --- /dev/null +++ b/packages/opencode/test/util/image-optimizer.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test, beforeAll, afterAll } from "bun:test" +import { ImageOptimizer } from "../../src/util/image-optimizer" +import { Transformer } from "@napi-rs/image" +import { getLargeTestImage, getSmallTestImage, clearImageCache } from "../fixture/image" + +describe("ImageOptimizer", () => { + describe("formatBytes", () => { + test("formats bytes correctly", () => { + expect(ImageOptimizer.formatBytes(500)).toBe("500 B") + expect(ImageOptimizer.formatBytes(1024)).toBe("1.00 KB") + expect(ImageOptimizer.formatBytes(1024 * 1024)).toBe("1.00 MB") + expect(ImageOptimizer.formatBytes(5.5 * 1024 * 1024)).toBe("5.50 MB") + }) + + test("formats edge cases", () => { + expect(ImageOptimizer.formatBytes(0)).toBe("0 B") + expect(ImageOptimizer.formatBytes(1)).toBe("1 B") + expect(ImageOptimizer.formatBytes(1023)).toBe("1023 B") + expect(ImageOptimizer.formatBytes(1024 * 1024 * 10)).toBe("10.00 MB") + }) + }) + + describe("needsOptimization", () => { + test("returns false for images under 5MB", () => { + expect(ImageOptimizer.needsOptimization(1024 * 1024)).toBe(false) + expect(ImageOptimizer.needsOptimization(ImageOptimizer.SIZE_LIMIT - 1)).toBe(false) + expect(ImageOptimizer.needsOptimization(0)).toBe(false) + }) + + test("returns true for images over 5MB", () => { + expect(ImageOptimizer.needsOptimization(ImageOptimizer.SIZE_LIMIT + 1)).toBe(true) + expect(ImageOptimizer.needsOptimization(10 * 1024 * 1024)).toBe(true) + expect(ImageOptimizer.needsOptimization(ImageOptimizer.SIZE_LIMIT * 2)).toBe(true) + }) + + test("returns false for images exactly at 5MB threshold", () => { + expect(ImageOptimizer.needsOptimization(ImageOptimizer.SIZE_LIMIT)).toBe(false) + }) + }) + + describe("optimize", () => { + let testImage: Awaited> | null = null + let optimizedResult: ImageOptimizer.OptimizationResult | null = null + + beforeAll(async () => { + testImage = await getLargeTestImage() + optimizedResult = await ImageOptimizer.optimize(testImage.buffer) + }) + + afterAll(() => { + clearImageCache() + }) + + test("optimizes large image under size limit", () => { + expect(testImage).not.toBeNull() + expect(optimizedResult).not.toBeNull() + expect(testImage!.size).toBeGreaterThan(ImageOptimizer.SIZE_LIMIT) + + const resultSize = Buffer.from(optimizedResult!.data, "base64").length + expect(resultSize).toBeLessThanOrEqual(ImageOptimizer.SIZE_LIMIT) + }) + + test("reduces dimensions when needed", async () => { + expect(optimizedResult).not.toBeNull() + expect(testImage).not.toBeNull() + + const resultBuffer = Buffer.from(optimizedResult!.data, "base64") + const metadata = await new Transformer(resultBuffer).metadata() + + expect(metadata.width).toBeLessThan(testImage!.width) + expect(metadata.height).toBeLessThan(testImage!.height) + expect(metadata.width).toBeGreaterThanOrEqual(100) + expect(metadata.height).toBeGreaterThanOrEqual(100) + }) + + test("result is valid base64", () => { + expect(optimizedResult).not.toBeNull() + expect(optimizedResult!.data).toMatch(/^[A-Za-z0-9+/]*={0,2}$/) + + const buffer = Buffer.from(optimizedResult!.data, "base64") + expect(buffer.length).toBeGreaterThan(0) + }) + + test("preserves aspect ratio", async () => { + expect(optimizedResult).not.toBeNull() + expect(testImage).not.toBeNull() + + const originalAspect = testImage!.width / testImage!.height + const resultBuffer = Buffer.from(optimizedResult!.data, "base64") + const metadata = await new Transformer(resultBuffer).metadata() + const finalAspect = metadata.width / metadata.height + + expect(Math.abs(finalAspect - originalAspect)).toBeLessThan(originalAspect * 0.01) + }) + + test("opaque PNG converts to JPEG", () => { + expect(optimizedResult).not.toBeNull() + expect(optimizedResult!.mime).toBe("image/jpeg") + }) + }) + + describe("optimize (small image - no optimization needed)", () => { + let smallImage: Awaited> | null = null + let result: ImageOptimizer.OptimizationResult | null = null + + beforeAll(async () => { + smallImage = await getSmallTestImage() + result = await ImageOptimizer.optimize(smallImage.buffer) + }) + + afterAll(() => { + clearImageCache() + }) + + test("small image is under size limit", () => { + expect(smallImage).not.toBeNull() + expect(smallImage!.size).toBeLessThan(ImageOptimizer.SIZE_LIMIT) + }) + + test("returns image without dimension reduction", async () => { + expect(result).not.toBeNull() + expect(smallImage).not.toBeNull() + + const resultBuffer = Buffer.from(result!.data, "base64") + const metadata = await new Transformer(resultBuffer).metadata() + + // Dimensions should remain the same since no optimization was needed + expect(metadata.width).toBe(smallImage!.width) + expect(metadata.height).toBe(smallImage!.height) + }) + + test("result is valid base64", () => { + expect(result).not.toBeNull() + expect(result!.data).toMatch(/^[A-Za-z0-9+/]*={0,2}$/) + }) + }) + + describe("Algorithm behavior", () => { + test("SIZE_LIMIT is correctly defined as 5MB", () => { + expect(ImageOptimizer.SIZE_LIMIT).toBe(5 * 1024 * 1024) + }) + }) +})