fix: preserve PNG alpha fallback (#1491) (thanks @robbyczgw-cla)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<ReturnType<typeof optimizeImageToPng>> | null = null;
|
||||
let jpegOptimized: Awaited<ReturnType<typeof optimizeImageToJpeg>> | 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user