fix: improve web image optimization
This commit is contained in:
@@ -382,6 +382,65 @@ export async function resizeToPng(params: {
|
|||||||
.toBuffer();
|
.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).
|
* Internal sips-only EXIF normalization (no sharp fallback).
|
||||||
* Used by resizeToJpeg to normalize before sips resize.
|
* Used by resizeToJpeg to normalize before sips resize.
|
||||||
|
|||||||
@@ -5,10 +5,21 @@ 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, optimizeImageToJpeg, optimizeImageToPng } from "./media.js";
|
import { optimizeImageToPng } from "../media/image-ops.js";
|
||||||
|
import { loadWebMedia, optimizeImageToJpeg } from "./media.js";
|
||||||
|
|
||||||
const tmpFiles: string[] = [];
|
const tmpFiles: string[] = [];
|
||||||
|
|
||||||
|
async function writeTempFile(buffer: Buffer, ext: string): Promise<string> {
|
||||||
|
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 {
|
function buildDeterministicBytes(length: number): Buffer {
|
||||||
const buffer = Buffer.allocUnsafe(length);
|
const buffer = Buffer.allocUnsafe(length);
|
||||||
let seed = 0x12345678;
|
let seed = 0x12345678;
|
||||||
@@ -37,9 +48,7 @@ describe("web media loading", () => {
|
|||||||
.jpeg({ quality: 95 })
|
.jpeg({ quality: 95 })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
const file = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}.jpg`);
|
const file = await writeTempFile(buffer, ".jpg");
|
||||||
tmpFiles.push(file);
|
|
||||||
await fs.writeFile(file, buffer);
|
|
||||||
|
|
||||||
const cap = Math.floor(buffer.length * 0.8);
|
const cap = Math.floor(buffer.length * 0.8);
|
||||||
const result = await loadWebMedia(file, cap);
|
const result = await loadWebMedia(file, cap);
|
||||||
@@ -55,9 +64,7 @@ describe("web media loading", () => {
|
|||||||
})
|
})
|
||||||
.png()
|
.png()
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
const wrongExt = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}.bin`);
|
const wrongExt = await writeTempFile(pngBuffer, ".bin");
|
||||||
tmpFiles.push(wrongExt);
|
|
||||||
await fs.writeFile(wrongExt, pngBuffer);
|
|
||||||
|
|
||||||
const result = await loadWebMedia(wrongExt, 1024 * 1024);
|
const result = await loadWebMedia(wrongExt, 1024 * 1024);
|
||||||
|
|
||||||
@@ -160,9 +167,7 @@ describe("web media loading", () => {
|
|||||||
0x3b, // minimal LZW data + trailer
|
0x3b, // minimal LZW data + trailer
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const file = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}.gif`);
|
const file = await writeTempFile(gifBuffer, ".gif");
|
||||||
tmpFiles.push(file);
|
|
||||||
await fs.writeFile(file, gifBuffer);
|
|
||||||
|
|
||||||
const result = await loadWebMedia(file, 1024 * 1024);
|
const result = await loadWebMedia(file, 1024 * 1024);
|
||||||
|
|
||||||
@@ -208,9 +213,7 @@ describe("web media loading", () => {
|
|||||||
.png()
|
.png()
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
const file = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}.png`);
|
const file = await writeTempFile(buffer, ".png");
|
||||||
tmpFiles.push(file);
|
|
||||||
await fs.writeFile(file, buffer);
|
|
||||||
|
|
||||||
const result = await loadWebMedia(file, 1024 * 1024);
|
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`);
|
const file = await writeTempFile(pngBuffer, ".png");
|
||||||
tmpFiles.push(file);
|
|
||||||
await fs.writeFile(file, pngBuffer);
|
|
||||||
|
|
||||||
const result = await loadWebMedia(file, cap);
|
const result = await loadWebMedia(file, cap);
|
||||||
|
|
||||||
|
|||||||
202
src/web/media.ts
202
src/web/media.ts
@@ -9,8 +9,8 @@ import { fetchRemoteMedia } from "../media/fetch.js";
|
|||||||
import {
|
import {
|
||||||
convertHeicToJpeg,
|
convertHeicToJpeg,
|
||||||
hasAlphaChannel,
|
hasAlphaChannel,
|
||||||
|
optimizeImageToPng,
|
||||||
resizeToJpeg,
|
resizeToJpeg,
|
||||||
resizeToPng,
|
|
||||||
} from "../media/image-ops.js";
|
} from "../media/image-ops.js";
|
||||||
import { detectMime, extensionForMime } from "../media/mime.js";
|
import { detectMime, extensionForMime } from "../media/mime.js";
|
||||||
|
|
||||||
@@ -28,6 +28,19 @@ type WebMediaOptions = {
|
|||||||
|
|
||||||
const HEIC_MIME_RE = /^image\/hei[cf]$/i;
|
const HEIC_MIME_RE = /^image\/hei[cf]$/i;
|
||||||
const HEIC_EXT_RE = /\.(heic|heif)$/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 {
|
function isHeicSource(opts: { contentType?: string; fileName?: string }): boolean {
|
||||||
if (opts.contentType && HEIC_MIME_RE.test(opts.contentType.trim())) return true;
|
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" });
|
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<OptimizedImage> {
|
||||||
|
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(
|
async function loadWebMediaInternal(
|
||||||
mediaUrl: string,
|
mediaUrl: string,
|
||||||
options: WebMediaOptions = {},
|
options: WebMediaOptions = {},
|
||||||
@@ -66,59 +127,25 @@ async function loadWebMediaInternal(
|
|||||||
meta?: { contentType?: string; fileName?: string },
|
meta?: { contentType?: string; fileName?: string },
|
||||||
) => {
|
) => {
|
||||||
const originalSize = buffer.length;
|
const originalSize = buffer.length;
|
||||||
|
const optimized = await optimizeImageWithFallback({ buffer, cap, meta });
|
||||||
|
logOptimizedImage({ originalSize, optimized });
|
||||||
|
|
||||||
const optimizeToJpeg = async () => {
|
if (optimized.buffer.length > cap) {
|
||||||
const optimized = await optimizeImageToJpeg(buffer, cap, meta);
|
throw new Error(formatCapReduce("Media", cap, optimized.buffer.length));
|
||||||
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`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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: {
|
const clampAndFinalize = async (params: {
|
||||||
@@ -134,12 +161,7 @@ async function loadWebMediaInternal(
|
|||||||
const isGif = params.contentType === "image/gif";
|
const isGif = params.contentType === "image/gif";
|
||||||
if (isGif || !optimizeImages) {
|
if (isGif || !optimizeImages) {
|
||||||
if (params.buffer.length > cap) {
|
if (params.buffer.length > cap) {
|
||||||
throw new Error(
|
throw new Error(formatCapLimit(isGif ? "GIF" : "Media", cap, params.buffer.length));
|
||||||
`${isGif ? "GIF" : "Media"} exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
|
|
||||||
params.buffer.length /
|
|
||||||
(1024 * 1024)
|
|
||||||
).toFixed(2)}MB)`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
buffer: params.buffer,
|
buffer: params.buffer,
|
||||||
@@ -156,12 +178,7 @@ async function loadWebMediaInternal(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (params.buffer.length > cap) {
|
if (params.buffer.length > cap) {
|
||||||
throw new Error(
|
throw new Error(formatCapLimit("Media", cap, params.buffer.length));
|
||||||
`Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
|
|
||||||
params.buffer.length /
|
|
||||||
(1024 * 1024)
|
|
||||||
).toFixed(2)}MB)`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
buffer: params.buffer,
|
buffer: params.buffer,
|
||||||
@@ -284,61 +301,4 @@ export async function optimizeImageToJpeg(
|
|||||||
throw new Error("Failed to optimize image");
|
throw new Error("Failed to optimize image");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function optimizeImageToPng(
|
export { 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");
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user