diff --git a/CHANGELOG.md b/CHANGELOG.md index de0a72c8a..ee92edf62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Docs: https://docs.clawd.bot +## 2026.1.23 + +### Fixes +- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. + ## 2026.1.22 ### Changes diff --git a/src/web/media.test.ts b/src/web/media.test.ts index 177b75936..741afd0ad 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -5,10 +5,20 @@ import path from "node:path"; import sharp from "sharp"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { loadWebMedia } from "./media.js"; +import { loadWebMedia, optimizeImageToJpeg, optimizeImageToPng } from "./media.js"; const tmpFiles: string[] = []; +function buildDeterministicBytes(length: number): Buffer { + const buffer = Buffer.allocUnsafe(length); + let seed = 0x12345678; + for (let i = 0; i < length; i++) { + seed = (1103515245 * seed + 12345) & 0x7fffffff; + buffer[i] = seed & 0xff; + } + return buffer; +} + afterEach(async () => { await Promise.all(tmpFiles.map((file) => fs.rm(file, { force: true }))); tmpFiles.length = 0; @@ -185,4 +195,69 @@ describe("web media loading", () => { fetchMock.mockRestore(); }); + + it("preserves PNG alpha when under the cap", async () => { + const buffer = await sharp({ + create: { + width: 64, + height: 64, + channels: 4, + background: { r: 255, g: 0, b: 0, alpha: 0.5 }, + }, + }) + .png() + .toBuffer(); + + const file = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}.png`); + tmpFiles.push(file); + await fs.writeFile(file, buffer); + + const result = await loadWebMedia(file, 1024 * 1024); + + expect(result.kind).toBe("image"); + expect(result.contentType).toBe("image/png"); + const meta = await sharp(result.buffer).metadata(); + expect(meta.hasAlpha).toBe(true); + }); + + it("falls back to JPEG when PNG alpha cannot fit under cap", async () => { + const sizes = [512, 768, 1024]; + let pngBuffer: Buffer | null = null; + let smallestPng: Awaited> | null = null; + let jpegOptimized: Awaited> | null = null; + let cap = 0; + + for (const size of sizes) { + const raw = buildDeterministicBytes(size * size * 4); + pngBuffer = await sharp(raw, { raw: { width: size, height: size, channels: 4 } }) + .png() + .toBuffer(); + smallestPng = await optimizeImageToPng(pngBuffer, 1); + cap = Math.max(1, smallestPng.optimizedSize - 1); + jpegOptimized = await optimizeImageToJpeg(pngBuffer, cap); + if (jpegOptimized.buffer.length < smallestPng.optimizedSize) { + break; + } + } + + if (!pngBuffer || !smallestPng || !jpegOptimized) { + throw new Error("PNG fallback setup failed"); + } + + if (jpegOptimized.buffer.length >= smallestPng.optimizedSize) { + throw new Error( + `JPEG fallback did not shrink below PNG (jpeg=${jpegOptimized.buffer.length}, png=${smallestPng.optimizedSize})`, + ); + } + + const file = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}-alpha.png`); + tmpFiles.push(file); + await fs.writeFile(file, pngBuffer); + + const result = await loadWebMedia(file, cap); + + expect(result.kind).toBe("image"); + expect(result.contentType).toBe("image/jpeg"); + expect(result.buffer.length).toBeLessThanOrEqual(cap); + }); }); diff --git a/src/web/media.ts b/src/web/media.ts index 2478b7fb2..e161387df 100644 --- a/src/web/media.ts +++ b/src/web/media.ts @@ -67,17 +67,12 @@ async function loadWebMediaInternal( ) => { const originalSize = buffer.length; - // Check if this is a PNG with alpha channel - preserve transparency - const isPng = - meta?.contentType === "image/png" || meta?.fileName?.toLowerCase().endsWith(".png"); - const hasAlpha = isPng && (await hasAlphaChannel(buffer)); - - if (hasAlpha) { - // Use PNG optimization to preserve transparency - const optimized = await optimizeImageToPng(buffer, cap); + 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 PNG (preserving alpha) from ${(originalSize / (1024 * 1024)).toFixed(2)}MB to ${(optimized.optimizedSize / (1024 * 1024)).toFixed(2)}MB (side≤${optimized.resizeSide}px)`, + `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) { @@ -90,34 +85,40 @@ async function loadWebMediaInternal( } return { buffer: optimized.buffer, - contentType: "image/png", + contentType: "image/jpeg", kind: "image" as const, - fileName: meta?.fileName, + 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`, + ); + } } - // Default: optimize to JPEG (no alpha channel) - 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, - }; + return await optimizeToJpeg(); }; const clampAndFinalize = async (params: {