diff --git a/build.js b/build.js index e62a831..41b2a67 100644 --- a/build.js +++ b/build.js @@ -15,8 +15,16 @@ const __dirname = dirname(__filename); // Read version from package.json const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8")); const version = pkg.version; +const useMagick = Bun.env.USE_MAGICK; +const features = [] +const externalDeps = [] console.log(`📦 Building Letta Code v${version}...`); +if (useMagick) { + console.log(`🪄 Using magick variant of imageResize...`); + features.push("USE_MAGICK") + externalDeps.push("sharp") +} await Bun.build({ entrypoints: ["./src/index.ts"], @@ -38,6 +46,8 @@ await Bun.build({ ".mdx": "text", ".txt": "text", }, + features: features, + external: externalDeps, }); // Add shebang to output file diff --git a/bun.lock b/bun.lock index f3571a7..9099c73 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ "sharp": "^0.34.5", }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "^1.3.7", "@types/diff": "^8.0.0", "@types/picomatch": "^4.0.2", "@types/react": "^19.2.9", @@ -93,7 +93,7 @@ "@letta-ai/letta-client": ["@letta-ai/letta-client@1.7.6", "", {}, "sha512-C/f03uE3TJdgfHk/8rRBxzWvY0YHCYAlrePHcTd0CRHMo++0TA1OTcgiCF+EFVDVYGzfPSeMpqgAZTNvD9r9GQ=="], - "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], + "@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="], "@types/diff": ["@types/diff@8.0.0", "", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="], @@ -119,7 +119,7 @@ "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], - "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], + "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], diff --git a/package.json b/package.json index 608baef..c0873dc 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@vscode/ripgrep": "^1.17.0" }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "^1.3.7", "@types/diff": "^8.0.0", "@types/picomatch": "^4.0.2", "@types/react": "^19.2.9", diff --git a/src/cli/helpers/imageResize.magick.ts b/src/cli/helpers/imageResize.magick.ts new file mode 100644 index 0000000..ff72483 --- /dev/null +++ b/src/cli/helpers/imageResize.magick.ts @@ -0,0 +1,280 @@ +// Image resizing utilities for clipboard paste +// Follows Codex CLI's approach (codex-rs/utils/image/src/lib.rs) +import { execSync } from "node:child_process"; +import { readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Anthropic limits: 8000x8000 for single images, but 2000x2000 for many-image requests +// We use 2000 to stay safe when conversation history accumulates multiple images +export const MAX_IMAGE_WIDTH = 2000; +export const MAX_IMAGE_HEIGHT = 2000; + +// Anthropic's API enforces a 5MB limit on image bytes (not base64 string) +// We enforce this in the client to avoid API errors +export const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB = 5,242,880 bytes + +export interface ResizeResult { + data: string; // base64 encoded + mediaType: string; + width: number; + height: number; + resized: boolean; +} + +/** + * Get image dimensions using ImageMagick identify + */ +async function getImageDimensions( + buffer: Buffer, +): Promise<{ width: number; height: number; format: string }> { + const tempInput = join( + tmpdir(), + `image-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`, + ); + writeFileSync(tempInput, buffer); + + try { + const output = execSync( + `magick identify -format "%w %h %m" "${tempInput}"`, + { + encoding: "utf-8", + }, + ); + const [width, height, format] = output.trim().split(" "); + if (!width || !height || !format) { + throw new Error("Failed to get image dimensions"); + } + return { + width: parseInt(width, 10), + height: parseInt(height, 10), + format: format.toLowerCase(), + }; + } finally { + unlinkSync(tempInput); + } +} + +/** + * Compress an image to fit within MAX_IMAGE_BYTES using progressive JPEG quality reduction. + * If quality reduction alone isn't enough, also reduces dimensions. + * Returns null if compression is not needed (image already under limit). + */ +async function compressToFitByteLimit( + buffer: Buffer, + currentWidth: number, + currentHeight: number, +): Promise { + // Check if compression is needed + if (buffer.length <= MAX_IMAGE_BYTES) { + return null; // No compression needed + } + + const tempInput = join( + tmpdir(), + `compress-input-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`, + ); + writeFileSync(tempInput, buffer); + + try { + // Try progressive JPEG quality reduction + const qualities = [85, 70, 55, 40]; + for (const quality of qualities) { + const tempOutput = join( + tmpdir(), + `compress-output-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`, + ); + try { + execSync(`magick "${tempInput}" -quality ${quality} "${tempOutput}"`, { + stdio: "ignore", + }); + const compressed = readFileSync(tempOutput); + if (compressed.length <= MAX_IMAGE_BYTES) { + const { width, height } = await getImageDimensions(compressed); + return { + data: compressed.toString("base64"), + mediaType: "image/jpeg", + width, + height, + resized: true, + }; + } + } finally { + try { + unlinkSync(tempOutput); + } catch {} + } + } + + // Quality reduction wasn't enough - also reduce dimensions + const scales = [0.75, 0.5, 0.25]; + for (const scale of scales) { + const scaledWidth = Math.floor(currentWidth * scale); + const scaledHeight = Math.floor(currentHeight * scale); + const tempOutput = join( + tmpdir(), + `compress-output-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`, + ); + try { + execSync( + `magick "${tempInput}" -resize ${scaledWidth}x${scaledHeight} -quality 70 "${tempOutput}"`, + { + stdio: "ignore", + }, + ); + const reduced = readFileSync(tempOutput); + if (reduced.length <= MAX_IMAGE_BYTES) { + const { width, height } = await getImageDimensions(reduced); + return { + data: reduced.toString("base64"), + mediaType: "image/jpeg", + width, + height, + resized: true, + }; + } + } finally { + try { + unlinkSync(tempOutput); + } catch {} + } + } + + // Extremely rare: even 25% scale at q70 doesn't fit + throw new Error( + `Image too large: ${(buffer.length / 1024 / 1024).toFixed(1)}MB exceeds 5MB limit even after compression`, + ); + } finally { + unlinkSync(tempInput); + } +} + +/** + * Resize image if it exceeds MAX_IMAGE_WIDTH or MAX_IMAGE_HEIGHT. + * Uses 'inside' fit to preserve aspect ratio (like Codex's resize behavior). + * Returns original if already within limits and format is supported. + */ +export async function resizeImageIfNeeded( + buffer: Buffer, + inputMediaType: string, +): Promise { + const { width, height, format } = await getImageDimensions(buffer); + + const needsResize = width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT; + + // Determine if we can pass through the original format + const isPassthroughFormat = + format === "png" || format === "jpeg" || format === "jpg"; + + if (!needsResize && isPassthroughFormat) { + // No resize needed and format is supported - but check byte limit + const compressed = await compressToFitByteLimit(buffer, width, height); + if (compressed) { + return compressed; + } + return { + data: buffer.toString("base64"), + mediaType: inputMediaType, + width, + height, + resized: false, + }; + } + + const tempInput = join( + tmpdir(), + `resize-input-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`, + ); + writeFileSync(tempInput, buffer); + + try { + if (needsResize) { + // Resize preserving aspect ratio + // ImageMagick's -resize with geometry like "2000x2000>" preserves aspect ratio + // and only shrinks (doesn't enlarge) - equivalent to 'inside' fit + const tempOutput = join( + tmpdir(), + `resize-output-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + + let outputBuffer: Buffer; + let outputMediaType: string; + + if (format === "jpeg" || format === "jpg") { + // Preserve JPEG format with good quality (Codex uses 85) + execSync( + `magick "${tempInput}" -resize ${MAX_IMAGE_WIDTH}x${MAX_IMAGE_HEIGHT}> -quality 85 "${tempOutput}.jpg"`, + { + stdio: "ignore", + }, + ); + outputBuffer = readFileSync(`${tempOutput}.jpg`); + outputMediaType = "image/jpeg"; + unlinkSync(`${tempOutput}.jpg`); + } else { + // Default to PNG for everything else + execSync( + `magick "${tempInput}" -resize ${MAX_IMAGE_WIDTH}x${MAX_IMAGE_HEIGHT}> "${tempOutput}.png"`, + { + stdio: "ignore", + }, + ); + outputBuffer = readFileSync(`${tempOutput}.png`); + outputMediaType = "image/png"; + unlinkSync(`${tempOutput}.png`); + } + + const { width: resizedWidth, height: resizedHeight } = + await getImageDimensions(outputBuffer); + + // Check byte limit after dimension resize + const compressed = await compressToFitByteLimit( + outputBuffer, + resizedWidth, + resizedHeight, + ); + if (compressed) { + return compressed; + } + + return { + data: outputBuffer.toString("base64"), + mediaType: outputMediaType, + width: resizedWidth, + height: resizedHeight, + resized: true, + }; + } + + // No resize needed but format needs conversion (e.g., HEIC, TIFF, etc.) + const tempOutput = join( + tmpdir(), + `convert-output-${Date.now()}-${Math.random().toString(36).slice(2)}.png`, + ); + execSync(`magick "${tempInput}" "${tempOutput}"`, { + stdio: "ignore", + }); + const outputBuffer = readFileSync(tempOutput); + unlinkSync(tempOutput); + + // Check byte limit after format conversion + const compressed = await compressToFitByteLimit( + outputBuffer, + width, + height, + ); + if (compressed) { + return compressed; + } + + return { + data: outputBuffer.toString("base64"), + mediaType: "image/png", + width, + height, + resized: false, + }; + } finally { + unlinkSync(tempInput); + } +} diff --git a/src/cli/helpers/imageResize.sharp.ts b/src/cli/helpers/imageResize.sharp.ts new file mode 100644 index 0000000..efb1638 --- /dev/null +++ b/src/cli/helpers/imageResize.sharp.ts @@ -0,0 +1,179 @@ +// Image resizing utilities for clipboard paste +// Follows Codex CLI's approach (codex-rs/utils/image/src/lib.rs) +import sharp from "sharp"; + +// Anthropic limits: 8000x8000 for single images, but 2000x2000 for many-image requests +// We use 2000 to stay safe when conversation history accumulates multiple images +export const MAX_IMAGE_WIDTH = 2000; +export const MAX_IMAGE_HEIGHT = 2000; + +// Anthropic's API enforces a 5MB limit on image bytes (not base64 string) +// We enforce this in the client to avoid API errors +export const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB = 5,242,880 bytes + +export interface ResizeResult { + data: string; // base64 encoded + mediaType: string; + width: number; + height: number; + resized: boolean; +} + +/** + * Compress an image to fit within MAX_IMAGE_BYTES using progressive JPEG quality reduction. + * If quality reduction alone isn't enough, also reduces dimensions. + * Returns null if compression is not needed (image already under limit). + */ +async function compressToFitByteLimit( + buffer: Buffer, + currentWidth: number, + currentHeight: number, +): Promise { + // Check if compression is needed + if (buffer.length <= MAX_IMAGE_BYTES) { + return null; // No compression needed + } + + // Try progressive JPEG quality reduction + const qualities = [85, 70, 55, 40]; + for (const quality of qualities) { + const compressed = await sharp(buffer).jpeg({ quality }).toBuffer(); + if (compressed.length <= MAX_IMAGE_BYTES) { + const meta = await sharp(compressed).metadata(); + return { + data: compressed.toString("base64"), + mediaType: "image/jpeg", + width: meta.width ?? currentWidth, + height: meta.height ?? currentHeight, + resized: true, + }; + } + } + + // Quality reduction wasn't enough - also reduce dimensions + const scales = [0.75, 0.5, 0.25]; + for (const scale of scales) { + const scaledWidth = Math.floor(currentWidth * scale); + const scaledHeight = Math.floor(currentHeight * scale); + const reduced = await sharp(buffer) + .resize(scaledWidth, scaledHeight, { + fit: "inside", + withoutEnlargement: true, + }) + .jpeg({ quality: 70 }) + .toBuffer(); + if (reduced.length <= MAX_IMAGE_BYTES) { + const meta = await sharp(reduced).metadata(); + return { + data: reduced.toString("base64"), + mediaType: "image/jpeg", + width: meta.width ?? scaledWidth, + height: meta.height ?? scaledHeight, + resized: true, + }; + } + } + + // Extremely rare: even 25% scale at q70 doesn't fit + throw new Error( + `Image too large: ${(buffer.length / 1024 / 1024).toFixed(1)}MB exceeds 5MB limit even after compression`, + ); +} + +/** + * Resize image if it exceeds MAX_IMAGE_WIDTH or MAX_IMAGE_HEIGHT. + * Uses 'inside' fit to preserve aspect ratio (like Codex's resize behavior). + * Returns original if already within limits and format is supported. + */ +export async function resizeImageIfNeeded( + buffer: Buffer, + inputMediaType: string, +): Promise { + const image = sharp(buffer); + const metadata = await image.metadata(); + const width = metadata.width ?? 0; + const height = metadata.height ?? 0; + const format = metadata.format; + + const needsResize = width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT; + + // Determine if we can pass through the original format + const isPassthroughFormat = format === "png" || format === "jpeg"; + + if (!needsResize && isPassthroughFormat) { + // No resize needed and format is supported - but check byte limit + const compressed = await compressToFitByteLimit(buffer, width, height); + if (compressed) { + return compressed; + } + return { + data: buffer.toString("base64"), + mediaType: inputMediaType, + width, + height, + resized: false, + }; + } + + if (needsResize) { + // Resize preserving aspect ratio + // Use 'inside' fit which is equivalent to Codex's resize behavior + const resized = image.resize(MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT, { + fit: "inside", + withoutEnlargement: true, + }); + + // Output as PNG for lossless quality (or JPEG if input was JPEG) + let outputBuffer: Buffer; + let outputMediaType: string; + + if (format === "jpeg") { + // Preserve JPEG format with good quality (Codex uses 85) + outputBuffer = await resized.jpeg({ quality: 85 }).toBuffer(); + outputMediaType = "image/jpeg"; + } else { + // Default to PNG for everything else + outputBuffer = await resized.png().toBuffer(); + outputMediaType = "image/png"; + } + + const resizedMeta = await sharp(outputBuffer).metadata(); + const resizedWidth = resizedMeta.width ?? 0; + const resizedHeight = resizedMeta.height ?? 0; + + // Check byte limit after dimension resize + const compressed = await compressToFitByteLimit( + outputBuffer, + resizedWidth, + resizedHeight, + ); + if (compressed) { + return compressed; + } + + return { + data: outputBuffer.toString("base64"), + mediaType: outputMediaType, + width: resizedWidth, + height: resizedHeight, + resized: true, + }; + } + + // No resize needed but format needs conversion (e.g., HEIC, TIFF, etc.) + const outputBuffer = await image.png().toBuffer(); + + // Check byte limit after format conversion + const compressed = await compressToFitByteLimit(outputBuffer, width, height); + if (compressed) { + return compressed; + } + + return { + data: outputBuffer.toString("base64"), + mediaType: "image/png", + width, + height, + resized: false, + }; +} diff --git a/src/cli/helpers/imageResize.ts b/src/cli/helpers/imageResize.ts index efb1638..b701d07 100644 --- a/src/cli/helpers/imageResize.ts +++ b/src/cli/helpers/imageResize.ts @@ -1,15 +1,10 @@ // Image resizing utilities for clipboard paste // Follows Codex CLI's approach (codex-rs/utils/image/src/lib.rs) -import sharp from "sharp"; +import { feature } from "bun:bundle"; -// Anthropic limits: 8000x8000 for single images, but 2000x2000 for many-image requests -// We use 2000 to stay safe when conversation history accumulates multiple images export const MAX_IMAGE_WIDTH = 2000; export const MAX_IMAGE_HEIGHT = 2000; - -// Anthropic's API enforces a 5MB limit on image bytes (not base64 string) -// We enforce this in the client to avoid API errors -export const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB = 5,242,880 bytes +export const MAX_IMAGE_BYTES = 5 * 1024 * 1024; export interface ResizeResult { data: string; // base64 encoded @@ -19,161 +14,7 @@ export interface ResizeResult { resized: boolean; } -/** - * Compress an image to fit within MAX_IMAGE_BYTES using progressive JPEG quality reduction. - * If quality reduction alone isn't enough, also reduces dimensions. - * Returns null if compression is not needed (image already under limit). - */ -async function compressToFitByteLimit( - buffer: Buffer, - currentWidth: number, - currentHeight: number, -): Promise { - // Check if compression is needed - if (buffer.length <= MAX_IMAGE_BYTES) { - return null; // No compression needed - } - - // Try progressive JPEG quality reduction - const qualities = [85, 70, 55, 40]; - for (const quality of qualities) { - const compressed = await sharp(buffer).jpeg({ quality }).toBuffer(); - if (compressed.length <= MAX_IMAGE_BYTES) { - const meta = await sharp(compressed).metadata(); - return { - data: compressed.toString("base64"), - mediaType: "image/jpeg", - width: meta.width ?? currentWidth, - height: meta.height ?? currentHeight, - resized: true, - }; - } - } - - // Quality reduction wasn't enough - also reduce dimensions - const scales = [0.75, 0.5, 0.25]; - for (const scale of scales) { - const scaledWidth = Math.floor(currentWidth * scale); - const scaledHeight = Math.floor(currentHeight * scale); - const reduced = await sharp(buffer) - .resize(scaledWidth, scaledHeight, { - fit: "inside", - withoutEnlargement: true, - }) - .jpeg({ quality: 70 }) - .toBuffer(); - if (reduced.length <= MAX_IMAGE_BYTES) { - const meta = await sharp(reduced).metadata(); - return { - data: reduced.toString("base64"), - mediaType: "image/jpeg", - width: meta.width ?? scaledWidth, - height: meta.height ?? scaledHeight, - resized: true, - }; - } - } - - // Extremely rare: even 25% scale at q70 doesn't fit - throw new Error( - `Image too large: ${(buffer.length / 1024 / 1024).toFixed(1)}MB exceeds 5MB limit even after compression`, - ); -} - -/** - * Resize image if it exceeds MAX_IMAGE_WIDTH or MAX_IMAGE_HEIGHT. - * Uses 'inside' fit to preserve aspect ratio (like Codex's resize behavior). - * Returns original if already within limits and format is supported. - */ -export async function resizeImageIfNeeded( - buffer: Buffer, - inputMediaType: string, -): Promise { - const image = sharp(buffer); - const metadata = await image.metadata(); - const width = metadata.width ?? 0; - const height = metadata.height ?? 0; - const format = metadata.format; - - const needsResize = width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT; - - // Determine if we can pass through the original format - const isPassthroughFormat = format === "png" || format === "jpeg"; - - if (!needsResize && isPassthroughFormat) { - // No resize needed and format is supported - but check byte limit - const compressed = await compressToFitByteLimit(buffer, width, height); - if (compressed) { - return compressed; - } - return { - data: buffer.toString("base64"), - mediaType: inputMediaType, - width, - height, - resized: false, - }; - } - - if (needsResize) { - // Resize preserving aspect ratio - // Use 'inside' fit which is equivalent to Codex's resize behavior - const resized = image.resize(MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT, { - fit: "inside", - withoutEnlargement: true, - }); - - // Output as PNG for lossless quality (or JPEG if input was JPEG) - let outputBuffer: Buffer; - let outputMediaType: string; - - if (format === "jpeg") { - // Preserve JPEG format with good quality (Codex uses 85) - outputBuffer = await resized.jpeg({ quality: 85 }).toBuffer(); - outputMediaType = "image/jpeg"; - } else { - // Default to PNG for everything else - outputBuffer = await resized.png().toBuffer(); - outputMediaType = "image/png"; - } - - const resizedMeta = await sharp(outputBuffer).metadata(); - const resizedWidth = resizedMeta.width ?? 0; - const resizedHeight = resizedMeta.height ?? 0; - - // Check byte limit after dimension resize - const compressed = await compressToFitByteLimit( - outputBuffer, - resizedWidth, - resizedHeight, - ); - if (compressed) { - return compressed; - } - - return { - data: outputBuffer.toString("base64"), - mediaType: outputMediaType, - width: resizedWidth, - height: resizedHeight, - resized: true, - }; - } - - // No resize needed but format needs conversion (e.g., HEIC, TIFF, etc.) - const outputBuffer = await image.png().toBuffer(); - - // Check byte limit after format conversion - const compressed = await compressToFitByteLimit(outputBuffer, width, height); - if (compressed) { - return compressed; - } - - return { - data: outputBuffer.toString("base64"), - mediaType: "image/png", - width, - height, - resized: false, - }; -} +// Import the correct implementation based on feature flag +export const resizeImageIfNeeded = feature("USE_MAGICK") + ? (await import("./imageResize.magick.js")).resizeImageIfNeeded + : (await import("./imageResize.sharp.js")).resizeImageIfNeeded;