fix: preserve PNG alpha fallback (#1491) (thanks @robbyczgw-cla)
This commit is contained in:
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
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
|
## 2026.1.22
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -5,10 +5,20 @@ import path from "node:path";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { loadWebMedia } from "./media.js";
|
import { loadWebMedia, optimizeImageToJpeg, optimizeImageToPng } from "./media.js";
|
||||||
|
|
||||||
const tmpFiles: string[] = [];
|
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 () => {
|
afterEach(async () => {
|
||||||
await Promise.all(tmpFiles.map((file) => fs.rm(file, { force: true })));
|
await Promise.all(tmpFiles.map((file) => fs.rm(file, { force: true })));
|
||||||
tmpFiles.length = 0;
|
tmpFiles.length = 0;
|
||||||
@@ -185,4 +195,69 @@ describe("web media loading", () => {
|
|||||||
|
|
||||||
fetchMock.mockRestore();
|
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;
|
const originalSize = buffer.length;
|
||||||
|
|
||||||
// Check if this is a PNG with alpha channel - preserve transparency
|
const optimizeToJpeg = async () => {
|
||||||
const isPng =
|
const optimized = await optimizeImageToJpeg(buffer, cap, meta);
|
||||||
meta?.contentType === "image/png" || meta?.fileName?.toLowerCase().endsWith(".png");
|
const fileName = meta && isHeicSource(meta) ? toJpegFileName(meta.fileName) : meta?.fileName;
|
||||||
const hasAlpha = isPng && (await hasAlphaChannel(buffer));
|
|
||||||
|
|
||||||
if (hasAlpha) {
|
|
||||||
// Use PNG optimization to preserve transparency
|
|
||||||
const optimized = await optimizeImageToPng(buffer, cap);
|
|
||||||
if (optimized.optimizedSize < originalSize && shouldLogVerbose()) {
|
if (optimized.optimizedSize < originalSize && shouldLogVerbose()) {
|
||||||
logVerbose(
|
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) {
|
if (optimized.buffer.length > cap) {
|
||||||
@@ -90,34 +85,40 @@ async function loadWebMediaInternal(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
buffer: optimized.buffer,
|
buffer: optimized.buffer,
|
||||||
contentType: "image/png",
|
contentType: "image/jpeg",
|
||||||
kind: "image" as const,
|
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)
|
return await optimizeToJpeg();
|
||||||
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,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clampAndFinalize = async (params: {
|
const clampAndFinalize = async (params: {
|
||||||
|
|||||||
Reference in New Issue
Block a user