From 350131b4d7a0b1465432bb8482eefa7cf6224ed2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 01:15:04 +0000 Subject: [PATCH] fix: improve web image optimization --- src/media/image-ops.ts | 59 ++++++++++++ src/web/media.test.ts | 33 +++---- src/web/media.ts | 202 +++++++++++++++++------------------------ 3 files changed, 157 insertions(+), 137 deletions(-) diff --git a/src/media/image-ops.ts b/src/media/image-ops.ts index f87bdcd42..156d7774d 100644 --- a/src/media/image-ops.ts +++ b/src/media/image-ops.ts @@ -382,6 +382,65 @@ export async function resizeToPng(params: { .toBuffer(); } +export async function optimizeImageToPng( + buffer: Buffer, + maxBytes: number, +): Promise<{ + buffer: Buffer; + optimizedSize: number; + resizeSide: number; + compressionLevel: number; +}> { + // Try a grid of sizes/compression levels until under the limit. + // PNG uses compression levels 0-9 (higher = smaller but slower). + const sides = [2048, 1536, 1280, 1024, 800]; + const compressionLevels = [6, 7, 8, 9]; + let smallest: { + buffer: Buffer; + size: number; + resizeSide: number; + compressionLevel: number; + } | null = null; + + for (const side of sides) { + for (const compressionLevel of compressionLevels) { + try { + const out = await resizeToPng({ + buffer, + maxSide: side, + compressionLevel, + withoutEnlargement: true, + }); + const size = out.length; + if (!smallest || size < smallest.size) { + smallest = { buffer: out, size, resizeSide: side, compressionLevel }; + } + if (size <= maxBytes) { + return { + buffer: out, + optimizedSize: size, + resizeSide: side, + compressionLevel, + }; + } + } catch { + // Continue trying other size/compression combinations. + } + } + } + + if (smallest) { + return { + buffer: smallest.buffer, + optimizedSize: smallest.size, + resizeSide: smallest.resizeSide, + compressionLevel: smallest.compressionLevel, + }; + } + + throw new Error("Failed to optimize PNG image"); +} + /** * Internal sips-only EXIF normalization (no sharp fallback). * Used by resizeToJpeg to normalize before sips resize. diff --git a/src/web/media.test.ts b/src/web/media.test.ts index 741afd0ad..d691b1c8e 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -5,10 +5,21 @@ import path from "node:path"; import sharp from "sharp"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { loadWebMedia, optimizeImageToJpeg, optimizeImageToPng } from "./media.js"; +import { optimizeImageToPng } from "../media/image-ops.js"; +import { loadWebMedia, optimizeImageToJpeg } from "./media.js"; const tmpFiles: string[] = []; +async function writeTempFile(buffer: Buffer, ext: string): Promise { + const file = path.join( + os.tmpdir(), + `clawdbot-media-${Date.now()}-${Math.random().toString(16).slice(2)}${ext}`, + ); + tmpFiles.push(file); + await fs.writeFile(file, buffer); + return file; +} + function buildDeterministicBytes(length: number): Buffer { const buffer = Buffer.allocUnsafe(length); let seed = 0x12345678; @@ -37,9 +48,7 @@ describe("web media loading", () => { .jpeg({ quality: 95 }) .toBuffer(); - const file = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}.jpg`); - tmpFiles.push(file); - await fs.writeFile(file, buffer); + const file = await writeTempFile(buffer, ".jpg"); const cap = Math.floor(buffer.length * 0.8); const result = await loadWebMedia(file, cap); @@ -55,9 +64,7 @@ describe("web media loading", () => { }) .png() .toBuffer(); - const wrongExt = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}.bin`); - tmpFiles.push(wrongExt); - await fs.writeFile(wrongExt, pngBuffer); + const wrongExt = await writeTempFile(pngBuffer, ".bin"); const result = await loadWebMedia(wrongExt, 1024 * 1024); @@ -160,9 +167,7 @@ describe("web media loading", () => { 0x3b, // minimal LZW data + trailer ]); - const file = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}.gif`); - tmpFiles.push(file); - await fs.writeFile(file, gifBuffer); + const file = await writeTempFile(gifBuffer, ".gif"); const result = await loadWebMedia(file, 1024 * 1024); @@ -208,9 +213,7 @@ describe("web media loading", () => { .png() .toBuffer(); - const file = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}.png`); - tmpFiles.push(file); - await fs.writeFile(file, buffer); + const file = await writeTempFile(buffer, ".png"); const result = await loadWebMedia(file, 1024 * 1024); @@ -250,9 +253,7 @@ describe("web media loading", () => { ); } - const file = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}-alpha.png`); - tmpFiles.push(file); - await fs.writeFile(file, pngBuffer); + const file = await writeTempFile(pngBuffer, ".png"); const result = await loadWebMedia(file, cap); diff --git a/src/web/media.ts b/src/web/media.ts index e161387df..72f6d34de 100644 --- a/src/web/media.ts +++ b/src/web/media.ts @@ -9,8 +9,8 @@ import { fetchRemoteMedia } from "../media/fetch.js"; import { convertHeicToJpeg, hasAlphaChannel, + optimizeImageToPng, resizeToJpeg, - resizeToPng, } from "../media/image-ops.js"; import { detectMime, extensionForMime } from "../media/mime.js"; @@ -28,6 +28,19 @@ type WebMediaOptions = { const HEIC_MIME_RE = /^image\/hei[cf]$/i; const HEIC_EXT_RE = /\.(heic|heif)$/i; +const MB = 1024 * 1024; + +function formatMb(bytes: number, digits = 2): string { + return (bytes / MB).toFixed(digits); +} + +function formatCapLimit(label: string, cap: number, size: number): string { + return `${label} exceeds ${formatMb(cap, 0)}MB limit (got ${formatMb(size)}MB)`; +} + +function formatCapReduce(label: string, cap: number, size: number): string { + return `${label} could not be reduced below ${formatMb(cap, 0)}MB (got ${formatMb(size)}MB)`; +} function isHeicSource(opts: { contentType?: string; fileName?: string }): boolean { if (opts.contentType && HEIC_MIME_RE.test(opts.contentType.trim())) return true; @@ -46,6 +59,54 @@ function toJpegFileName(fileName?: string): string | undefined { return path.format({ dir: parsed.dir, name: parsed.name, ext: ".jpg" }); } +type OptimizedImage = { + buffer: Buffer; + optimizedSize: number; + resizeSide: number; + format: "jpeg" | "png"; + quality?: number; + compressionLevel?: number; +}; + +function logOptimizedImage(params: { originalSize: number; optimized: OptimizedImage }): void { + if (!shouldLogVerbose()) return; + if (params.optimized.optimizedSize >= params.originalSize) return; + if (params.optimized.format === "png") { + logVerbose( + `Optimized PNG (preserving alpha) from ${formatMb(params.originalSize)}MB to ${formatMb(params.optimized.optimizedSize)}MB (side≤${params.optimized.resizeSide}px)`, + ); + return; + } + logVerbose( + `Optimized media from ${formatMb(params.originalSize)}MB to ${formatMb(params.optimized.optimizedSize)}MB (side≤${params.optimized.resizeSide}px, q=${params.optimized.quality})`, + ); +} + +async function optimizeImageWithFallback(params: { + buffer: Buffer; + cap: number; + meta?: { contentType?: string; fileName?: string }; +}): Promise { + const { buffer, cap, meta } = params; + const isPng = meta?.contentType === "image/png" || meta?.fileName?.toLowerCase().endsWith(".png"); + const hasAlpha = isPng && (await hasAlphaChannel(buffer)); + + if (hasAlpha) { + const optimized = await optimizeImageToPng(buffer, cap); + if (optimized.buffer.length <= cap) { + return { ...optimized, format: "png" }; + } + if (shouldLogVerbose()) { + logVerbose( + `PNG with alpha still exceeds ${formatMb(cap, 0)}MB after optimization; falling back to JPEG`, + ); + } + } + + const optimized = await optimizeImageToJpeg(buffer, cap, meta); + return { ...optimized, format: "jpeg" }; +} + async function loadWebMediaInternal( mediaUrl: string, options: WebMediaOptions = {}, @@ -66,59 +127,25 @@ async function loadWebMediaInternal( meta?: { contentType?: string; fileName?: string }, ) => { const originalSize = buffer.length; + const optimized = await optimizeImageWithFallback({ buffer, cap, meta }); + logOptimizedImage({ originalSize, optimized }); - const optimizeToJpeg = async () => { - const optimized = await optimizeImageToJpeg(buffer, cap, meta); - const fileName = meta && isHeicSource(meta) ? toJpegFileName(meta.fileName) : meta?.fileName; - if (optimized.optimizedSize < originalSize && shouldLogVerbose()) { - logVerbose( - `Optimized media from ${(originalSize / (1024 * 1024)).toFixed(2)}MB to ${(optimized.optimizedSize / (1024 * 1024)).toFixed(2)}MB (side≤${optimized.resizeSide}px, q=${optimized.quality})`, - ); - } - if (optimized.buffer.length > cap) { - throw new Error( - `Media could not be reduced below ${(cap / (1024 * 1024)).toFixed(0)}MB (got ${( - optimized.buffer.length / - (1024 * 1024) - ).toFixed(2)}MB)`, - ); - } - return { - buffer: optimized.buffer, - contentType: "image/jpeg", - kind: "image" as const, - fileName, - }; - }; - - // Check if this is a PNG with alpha channel - preserve transparency when possible - const isPng = - meta?.contentType === "image/png" || meta?.fileName?.toLowerCase().endsWith(".png"); - const hasAlpha = isPng && (await hasAlphaChannel(buffer)); - - if (hasAlpha) { - const optimized = await optimizeImageToPng(buffer, cap); - if (optimized.buffer.length <= cap) { - if (optimized.optimizedSize < originalSize && shouldLogVerbose()) { - logVerbose( - `Optimized PNG (preserving alpha) from ${(originalSize / (1024 * 1024)).toFixed(2)}MB to ${(optimized.optimizedSize / (1024 * 1024)).toFixed(2)}MB (side≤${optimized.resizeSide}px)`, - ); - } - return { - buffer: optimized.buffer, - contentType: "image/png", - kind: "image" as const, - fileName: meta?.fileName, - }; - } - if (shouldLogVerbose()) { - logVerbose( - `PNG with alpha still exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB after optimization; falling back to JPEG`, - ); - } + if (optimized.buffer.length > cap) { + throw new Error(formatCapReduce("Media", cap, optimized.buffer.length)); } - return await optimizeToJpeg(); + const contentType = optimized.format === "png" ? "image/png" : "image/jpeg"; + const fileName = + optimized.format === "jpeg" && meta && isHeicSource(meta) + ? toJpegFileName(meta.fileName) + : meta?.fileName; + + return { + buffer: optimized.buffer, + contentType, + kind: "image" as const, + fileName, + }; }; const clampAndFinalize = async (params: { @@ -134,12 +161,7 @@ async function loadWebMediaInternal( const isGif = params.contentType === "image/gif"; if (isGif || !optimizeImages) { if (params.buffer.length > cap) { - throw new Error( - `${isGif ? "GIF" : "Media"} exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${( - params.buffer.length / - (1024 * 1024) - ).toFixed(2)}MB)`, - ); + throw new Error(formatCapLimit(isGif ? "GIF" : "Media", cap, params.buffer.length)); } return { buffer: params.buffer, @@ -156,12 +178,7 @@ async function loadWebMediaInternal( }; } if (params.buffer.length > cap) { - throw new Error( - `Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${( - params.buffer.length / - (1024 * 1024) - ).toFixed(2)}MB)`, - ); + throw new Error(formatCapLimit("Media", cap, params.buffer.length)); } return { buffer: params.buffer, @@ -284,61 +301,4 @@ export async function optimizeImageToJpeg( throw new Error("Failed to optimize image"); } -export async function optimizeImageToPng( - buffer: Buffer, - maxBytes: number, -): Promise<{ - buffer: Buffer; - optimizedSize: number; - resizeSide: number; - compressionLevel: number; -}> { - // Try a grid of sizes/compression levels until under the limit. - // PNG uses compression levels 0-9 (higher = smaller but slower) - const sides = [2048, 1536, 1280, 1024, 800]; - const compressionLevels = [6, 7, 8, 9]; - let smallest: { - buffer: Buffer; - size: number; - resizeSide: number; - compressionLevel: number; - } | null = null; - - for (const side of sides) { - for (const compressionLevel of compressionLevels) { - try { - const out = await resizeToPng({ - buffer, - maxSide: side, - compressionLevel, - withoutEnlargement: true, - }); - const size = out.length; - if (!smallest || size < smallest.size) { - smallest = { buffer: out, size, resizeSide: side, compressionLevel }; - } - if (size <= maxBytes) { - return { - buffer: out, - optimizedSize: size, - resizeSide: side, - compressionLevel, - }; - } - } catch { - // Continue trying other size/compression combinations - } - } - } - - if (smallest) { - return { - buffer: smallest.buffer, - optimizedSize: smallest.size, - resizeSide: smallest.resizeSide, - compressionLevel: smallest.compressionLevel, - }; - } - - throw new Error("Failed to optimize PNG image"); -} +export { optimizeImageToPng };