mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user