fix(api): make pixelate actually pixelate (#709)

Sharp keeps one set of resize options per pipeline, so the chained shrink-then-grow collapsed into a single resize back to the original size and both the full-image and region paths returned the input untouched.

Run the two resizes as separate pipelines, pinned to fit: "fill" so a skewed block grid cannot crop the picture.

Also in this change:
- stop passing quality to the PNG encoder, which Sharp reads as "quantise to a palette" and which dithered the flat blocks and inflated the file
- replace the region instead of blending into it, so a part-transparent image no longer shows the original through the mosaic
- throw on unreadable dimensions rather than falling back to a 1x1 image

Five new integration tests on content-bearing fixtures, each verified against a deliberate mutant.

Fixes #678
This commit is contained in:
SnapOtter
2026-08-02 12:03:04 +08:00
committed by GitHub
parent 50d12c6aba
commit 5ffede05ef
2 changed files with 245 additions and 27 deletions
+70 -27
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import sharp, { type Sharp } from "sharp";
import { z } from "zod";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { InputValidationError } from "../../modality/contract.js";
@@ -17,17 +17,60 @@ const settingsSchema = z.object({
.optional(),
});
/**
* Shrink to one pixel per block, then blow it back up with nearest-neighbour.
*
* The two resizes have to run as separate pipelines. Sharp holds a single set
* of resize options per pipeline, so chaining them lets the second call
* replace the first and the image comes back untouched (issue #678).
*
* `fit: "fill"` pins both steps to exact dimensions; the default `cover` would
* crop whenever the block grid does not divide the image evenly.
*/
async function mosaic(
source: Sharp,
width: number,
height: number,
blockSize: number,
): Promise<Buffer> {
const cols = Math.max(1, Math.round(width / blockSize));
const rows = Math.max(1, Math.round(height / blockSize));
// The shrink resamples with the default kernel, so a block's colour is drawn
// from the region it covers rather than one sampled pixel. Nearest-neighbour
// on the way back up is what keeps the block edges hard.
const shrunk = await source.resize(cols, rows, { fit: "fill" }).png().toBuffer();
return sharp(shrunk)
.resize(width, height, { fit: "fill", kernel: sharp.kernel.nearest })
.png()
.toBuffer();
}
/** A fully opaque rectangle, used as a stencil to clear the region being replaced. */
function opaqueRect(width: number, height: number): Promise<Buffer> {
return sharp({
create: { width, height, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
})
.png()
.toBuffer();
}
export function registerPixelate(app: FastifyInstance) {
createToolRoute(app, {
toolId: "pixelate",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
const meta = await sharp(inputBuffer).metadata();
const w = meta.width ?? 1;
const h = meta.height ?? 1;
if (!meta.width || !meta.height) {
// Defaulting to 1x1 here would hand back a one-pixel image with a 200.
throw new InputValidationError("Could not read image dimensions");
}
const w = meta.width;
const h = meta.height;
const bs = settings.blockSize;
let buf: Buffer;
let pixelated: Sharp;
if (settings.region) {
// Reject if origin is completely outside image bounds
@@ -43,34 +86,34 @@ export function registerPixelate(app: FastifyInstance) {
height: Math.min(settings.region.height, h - settings.region.top),
};
// Extract region, pixelate it, composite back
const rw = Math.max(1, Math.round(r.width / bs));
const rh = Math.max(1, Math.round(r.height / bs));
const region = await mosaic(sharp(inputBuffer).extract(r), r.width, r.height, bs);
const pixelatedRegion = await sharp(inputBuffer)
.extract({ left: r.left, top: r.top, width: r.width, height: r.height })
.resize(rw, rh, { kernel: sharp.kernel.nearest })
.resize(r.width, r.height, { kernel: sharp.kernel.nearest })
.toBuffer();
buf = await sharp(inputBuffer)
.composite([{ input: pixelatedRegion, left: r.left, top: r.top }])
.toBuffer();
// The mosaic has to REPLACE the region, not blend into it. Compositing
// straight over means any block whose averaged alpha is below 255 lets
// the original show through, so a partly transparent image keeps the
// detail the user asked to hide. Punching the region out with an opaque
// rectangle first leaves nothing underneath to bleed back in.
pixelated = sharp(inputBuffer)
.ensureAlpha()
.composite([
{
input: await opaqueRect(r.width, r.height),
left: r.left,
top: r.top,
blend: "dest-out",
},
{ input: region, left: r.left, top: r.top },
]);
} else {
// Full image pixelation
const smallW = Math.max(1, Math.round(w / bs));
const smallH = Math.max(1, Math.round(h / bs));
buf = await sharp(inputBuffer)
.resize(smallW, smallH, { kernel: sharp.kernel.nearest })
.resize(w, h, { kernel: sharp.kernel.nearest })
.toBuffer();
pixelated = sharp(await mosaic(sharp(inputBuffer), w, h, bs));
}
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
const buffer = await sharp(buf)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
// Sharp reads `quality` on PNG as "quantise down to a palette", which
// dithers the flat blocks this tool exists to produce and inflates the
// file. Every other encoder wants the hint.
const encodeOptions = outputFormat.format === "png" ? {} : { quality: outputFormat.quality };
const buffer = await pixelated.toFormat(outputFormat.format, encodeOptions).toBuffer();
const base = filename.replace(/\.[^.]+$/, "");
const ext = outputFormat.extension;
return {