fix: target-size compression now respects the target

The binary search found the right quality but sharp(buffer).toBuffer()
re-encoded at default quality 80, inflating the output (e.g. 50KB target
producing 90KB). Replaced buffer-wrapping with proper Sharp pipelines
that include .toFormat() with the proven quality. Also added progressive
dimension reduction when quality alone cannot reach the target, and
tightened tolerance to only accept at-or-below-target results.
This commit is contained in:
SnapOtter
2026-05-11 16:19:40 +08:00
parent 69597f3c39
commit e6a35265ef
3 changed files with 233 additions and 37 deletions
@@ -49,44 +49,75 @@ export async function compress(image: Sharp, options: CompressOptions): Promise<
return image.toFormat(outputFormat, formatOpts(outputFormat, q));
}
async function findBestQuality(
inputBuffer: Buffer,
resize: { width: number; height: number } | null,
format: keyof import("sharp").FormatEnum,
targetBytes: number,
): Promise<number | null> {
let low = 1;
let high = 100;
let bestQuality: number | null = null;
const maxIterations = 8;
const tolerance = 0.05;
for (let i = 0; i < maxIterations && low <= high; i++) {
const mid = Math.min(100, Math.max(1, Math.round((low + high) / 2)));
let pipeline = sharp(inputBuffer);
if (resize) pipeline = pipeline.resize(resize.width, resize.height);
const resultBuffer = await pipeline.toFormat(format, formatOpts(format, mid)).toBuffer();
const resultSize = resultBuffer.length;
if (resultSize <= targetBytes) {
bestQuality = mid;
if ((targetBytes - resultSize) / targetBytes <= tolerance) break;
low = mid + 1;
} else {
high = mid - 1;
}
}
return bestQuality;
}
async function compressToTargetSize(
inputBuffer: Buffer,
format: keyof import("sharp").FormatEnum,
targetBytes: number,
): Promise<Sharp> {
let low = 1;
let high = 100;
let bestQuality = 1;
let bestBuffer: Buffer | null = null;
const maxIterations = 8;
const tolerance = 0.05; // 5%
const quality = await findBestQuality(inputBuffer, null, format, targetBytes);
if (quality !== null) {
return sharp(inputBuffer).toFormat(format, formatOpts(format, quality));
}
for (let i = 0; i < maxIterations && low <= high; i++) {
const mid = Math.min(100, Math.max(1, Math.round((low + high) / 2)));
const attempt = sharp(inputBuffer).toFormat(format, formatOpts(format, mid));
const resultBuffer = await attempt.toBuffer();
const resultSize = resultBuffer.length;
const metadata = await sharp(inputBuffer).metadata();
const originalWidth = metadata.width ?? 0;
const originalHeight = metadata.height ?? 0;
if (Math.abs(resultSize - targetBytes) / targetBytes <= tolerance) {
bestQuality = mid;
bestBuffer = resultBuffer;
break;
}
if (originalWidth === 0 || originalHeight === 0) {
return sharp(inputBuffer).toFormat(format, formatOpts(format, 1));
}
if (resultSize > targetBytes) {
high = mid - 1;
} else {
low = mid + 1;
bestQuality = mid;
bestBuffer = resultBuffer;
const scaleFactor = 0.75;
const maxScalePasses = 8;
let lastWidth = originalWidth;
let lastHeight = originalHeight;
for (let pass = 1; pass <= maxScalePasses; pass++) {
const factor = scaleFactor ** pass;
const newWidth = Math.round(originalWidth * factor);
const newHeight = Math.round(originalHeight * factor);
if (newWidth < 10 || newHeight < 10) break;
lastWidth = newWidth;
lastHeight = newHeight;
const dims = { width: newWidth, height: newHeight };
const q = await findBestQuality(inputBuffer, dims, format, targetBytes);
if (q !== null) {
return sharp(inputBuffer).resize(newWidth, newHeight).toFormat(format, formatOpts(format, q));
}
}
if (bestBuffer === null) {
bestBuffer = await sharp(inputBuffer)
.toFormat(format, formatOpts(format, bestQuality))
.toBuffer();
}
return sharp(bestBuffer);
return sharp(inputBuffer).resize(lastWidth, lastHeight).toFormat(format, formatOpts(format, 1));
}
+39
View File
@@ -109,6 +109,45 @@ describe("Target size mode", () => {
const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined();
expect(result.processedSize).toBeGreaterThan(0);
expect(result.processedSize).toBeLessThanOrEqual(5 * 1024);
});
it("output is at or below target for aggressive reduction", async () => {
const largeBuf = await sharp({
create: { width: 1200, height: 900, channels: 3, background: "#4488cc" },
})
.jpeg({ quality: 100 })
.toBuffer();
const targetKb = 10;
const res = await postTool(
{ mode: "targetSize", targetSizeKb: targetKb },
largeBuf,
"large.jpg",
"image/jpeg",
);
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.processedSize).toBeLessThanOrEqual(targetKb * 1024);
expect(result.processedSize).toBeGreaterThan(0);
});
it("output is at or below target for PNG input", async () => {
const largePng = await sharp({
create: { width: 800, height: 600, channels: 4, background: "#cc2266" },
})
.png()
.toBuffer();
const targetKb = 5;
const res = await postTool(
{ mode: "targetSize", targetSizeKb: targetKb },
largePng,
"large.png",
"image/png",
);
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.processedSize).toBeLessThanOrEqual(targetKb * 1024);
expect(result.processedSize).toBeGreaterThan(0);
});
});
+134 -8
View File
@@ -677,7 +677,6 @@ describe("compress", () => {
});
it("target size binary search gets reasonably close", async () => {
// Create a bigger image for more compressibility headroom
const bigBuf = await sharp({
create: { width: 400, height: 300, channels: 3, background: "#884422" },
})
@@ -689,9 +688,7 @@ describe("compress", () => {
format: "jpg",
});
const buf = await result.toBuffer();
// Should be at or below target (with some tolerance)
// The algorithm tries to be within 5% or below target
expect(buf.length).toBeLessThan(targetBytes * 1.5);
expect(buf.length).toBeLessThanOrEqual(targetBytes);
});
it("uses input format when no format specified", async () => {
@@ -771,21 +768,150 @@ describe("compress", () => {
expect(meta.format).toBe("heif");
});
it("target size falls back to bestQuality=1 when bestBuffer stays null", async () => {
// Use a very tiny target that forces all iterations to overshoot,
// so bestBuffer never gets assigned and the fallback path runs
it("target size falls back to dimension reduction when quality alone fails", async () => {
const bigBuf = await sharp({
create: { width: 200, height: 200, channels: 3, background: "#ff8800" },
})
.jpeg({ quality: 100 })
.toBuffer();
// Target of 1 byte -- impossible to hit, so every iteration overshoots
const result = await compress(sharp(bigBuf), {
targetSizeBytes: 1,
format: "jpg",
});
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
const meta = await sharp(buf).metadata();
expect(meta.width).toBeLessThan(200);
});
});
// ---------------------------------------------------------------------------
// compress -- target-size accuracy
// ---------------------------------------------------------------------------
describe("compress target-size accuracy", () => {
it("output never exceeds target size for JPEG", async () => {
const largeBuf = await sharp({
create: { width: 800, height: 600, channels: 3, background: "#3366aa" },
})
.jpeg({ quality: 100 })
.toBuffer();
const targetBytes = 5000;
const result = await compress(sharp(largeBuf), {
targetSizeBytes: targetBytes,
format: "jpg",
});
const buf = await result.toBuffer();
expect(buf.length).toBeLessThanOrEqual(targetBytes);
});
it("output never exceeds target size for PNG", async () => {
const largeBuf = await sharp({
create: { width: 600, height: 400, channels: 4, background: "#cc4488" },
})
.png()
.toBuffer();
const targetBytes = 3000;
const result = await compress(sharp(largeBuf), {
targetSizeBytes: targetBytes,
format: "png",
});
const buf = await result.toBuffer();
expect(buf.length).toBeLessThanOrEqual(targetBytes);
});
it("output never exceeds target size for WebP", async () => {
const largeBuf = await sharp({
create: { width: 800, height: 600, channels: 3, background: "#99bb22" },
})
.webp({ quality: 100 })
.toBuffer();
const targetBytes = 4000;
const result = await compress(sharp(largeBuf), {
targetSizeBytes: targetBytes,
format: "webp",
});
const buf = await result.toBuffer();
expect(buf.length).toBeLessThanOrEqual(targetBytes);
});
it("aggressive target on large image triggers dimension reduction", async () => {
const width = 1200;
const height = 900;
const channels = 3;
const rawData = Buffer.alloc(width * height * channels);
for (let i = 0; i < rawData.length; i++) {
rawData[i] = (i * 7 + 13) % 256;
}
const largeBuf = await sharp(rawData, { raw: { width, height, channels } })
.jpeg({ quality: 95 })
.toBuffer();
const targetBytes = 5000;
const result = await compress(sharp(largeBuf), {
targetSizeBytes: targetBytes,
format: "jpg",
});
const buf = await result.toBuffer();
expect(buf.length).toBeLessThanOrEqual(targetBytes);
const meta = await sharp(buf).metadata();
expect(meta.width).toBeLessThan(1200);
expect(meta.height).toBeLessThan(900);
});
it("500KB-to-50KB scenario produces output at or below target", async () => {
const noisyBuf = await sharp({
create: { width: 1600, height: 1200, channels: 3, background: "#447799" },
})
.jpeg({ quality: 100 })
.toBuffer();
const targetBytes = 50 * 1024;
const result = await compress(sharp(noisyBuf), {
targetSizeBytes: targetBytes,
format: "jpg",
});
const buf = await result.toBuffer();
expect(buf.length).toBeLessThanOrEqual(targetBytes);
expect(buf.length).toBeGreaterThan(0);
});
it("preserves requested format during dimension reduction", async () => {
const width = 1200;
const height = 900;
const channels = 3;
const rawData = Buffer.alloc(width * height * channels);
for (let i = 0; i < rawData.length; i++) {
rawData[i] = (i * 7 + 13) % 256;
}
const largeBuf = await sharp(rawData, { raw: { width, height, channels } })
.jpeg({ quality: 95 })
.toBuffer();
const targetBytes = 5000;
const result = await compress(sharp(largeBuf), {
targetSizeBytes: targetBytes,
format: "webp",
});
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.format).toBe("webp");
expect(buf.length).toBeLessThanOrEqual(targetBytes);
expect(meta.width).toBeLessThan(1200);
});
it("does not reduce dimensions when quality alone suffices", async () => {
const buf = await sharp({
create: { width: 400, height: 300, channels: 3, background: "#112233" },
})
.jpeg({ quality: 95 })
.toBuffer();
const targetBytes = buf.length + 5000;
const result = await compress(sharp(buf), {
targetSizeBytes: targetBytes,
format: "jpg",
});
const outBuf = await result.toBuffer();
const meta = await sharp(outBuf).metadata();
expect(meta.width).toBe(400);
expect(meta.height).toBe(300);
expect(outBuf.length).toBeLessThanOrEqual(targetBytes);
});
});