/** * Image Processing * * Applies an `ImageEditRecipe` to an image on disk using sharp and returns the * encoded result as a Buffer. The client editor only ever sends a small * declarative recipe — the full-resolution original is read here so quality is * never bounded by what the browser downscaled for preview. * * Geometry is deterministic: for static images we fully bake rotate/flip into a * raw intermediate buffer first, so the crop rectangle always maps to the * orientation the user cropped against (sharp's single-pipeline op ordering is * not call-order, so we stage it ourselves). Animated images (GIF/WebP) are * processed in a single `{ animated: true }` pass to preserve their frames. */ import sharp from 'sharp'; import type { Sharp } from 'sharp'; import type { ImageEditRecipe, ImageEditRegion, ImageEditStroke } from '$shared/types/filesystem'; import { validateFileSize } from './file-size-limit'; export interface ProcessedImage { data: Buffer; format: string; width: number; height: number; size: number; } /** Hard cap on animation frames — prevents resource exhaustion via GIF bomb. */ const MAX_ANIMATION_FRAMES = 100; /** Clamp a value into [min, max], rounding to an integer. */ function clampInt(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, Math.round(value))); } /** Apply the color/tone adjustments shared by both processing paths. */ function applyColor(pipeline: Sharp, recipe: ImageEditRecipe): Sharp { let p = pipeline; if (recipe.background) { // Composite any transparency onto a solid colour (e.g. for JPEG export). p = p.flatten({ background: recipe.background }); } if (recipe.grayscale) { p = p.grayscale(); } const brightness = recipe.brightness ?? 1; const saturation = recipe.saturation ?? 1; const hue = Math.round(recipe.hue ?? 0); if (brightness !== 1 || saturation !== 1 || hue !== 0) { p = p.modulate({ brightness, saturation, hue }); } if (recipe.contrast != null && recipe.contrast !== 1) { // Linear contrast around the 128 midpoint: out = slope*in + intercept. p = p.linear(recipe.contrast, -(128 * recipe.contrast) + 128); } if (recipe.sepia) { // Standard sepia matrix (matches the CSS `sepia(1)` filter). p = p.recomb([ [0.393, 0.769, 0.189], [0.349, 0.686, 0.168], [0.272, 0.534, 0.131] ]); } if (recipe.invert) { p = p.negate({ alpha: false }); } if (recipe.blur != null && recipe.blur > 0) { p = p.blur(Math.max(0.3, recipe.blur)); } return p; } /** True when the recipe applies any geometry, color, region or annotation change. */ function hasEdits(recipe: ImageEditRecipe): boolean { return ( !!recipe.crop || !!recipe.resize || !!recipe.rotate || !!recipe.flipH || !!recipe.flipV || (recipe.brightness != null && recipe.brightness !== 1) || (recipe.saturation != null && recipe.saturation !== 1) || (recipe.contrast != null && recipe.contrast !== 1) || (recipe.hue != null && recipe.hue !== 0) || (recipe.blur != null && recipe.blur > 0) || !!recipe.grayscale || !!recipe.sepia || !!recipe.invert || (recipe.regions?.length ?? 0) > 0 || (recipe.annotations?.length ?? 0) > 0 ); } /** Build a composite layer that blurs or pixelates one region of the oriented image. */ async function buildRegionComposite( orientedBuf: Buffer, region: ImageEditRegion, imgWidth: number, imgHeight: number ): Promise<{ input: Buffer; left: number; top: number }> { const r = boundCrop( { left: region.left, top: region.top, width: region.width, height: region.height }, imgWidth, imgHeight ); const strength = Math.max(1, Math.min(100, region.strength || 1)); let img = sharp(orientedBuf).extract(r); if (region.type === 'blur') { img = img.blur(Math.max(0.3, strength / 3)); } else { // Mosaic: downscale with nearest-neighbour, then scale back up. const factor = Math.max(2, Math.round(strength / 3)); const dw = Math.max(1, Math.round(r.width / factor)); const dh = Math.max(1, Math.round(r.height / factor)); img = img .resize(dw, dh, { kernel: 'nearest' }) .resize(r.width, r.height, { kernel: 'nearest' }); } return { input: await img.png().toBuffer(), left: r.left, top: r.top }; } /** Render freehand strokes to an SVG overlay sized to the oriented image. */ function buildAnnotationSvg(strokes: ImageEditStroke[], width: number, height: number): string { const lines = strokes .map((s) => { const color = /^#[0-9a-fA-F]{3,8}$/.test(s.color) ? s.color : '#ef4444'; const w = Math.max(1, Math.min(400, s.size || 4)); const opacity = Math.max(0, Math.min(1, s.opacity ?? 1)); const pts = s.points .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) .map((p) => `${Math.round(p.x)},${Math.round(p.y)}`) .join(' '); if (!pts) return ''; return ``; }) .join(''); return `${lines}`; } /** * Encode the pipeline into the requested output format. * * `compress` opt-in (per format): * - off → preserve quality: lossless for PNG/WebP, high quality otherwise. The * caller copies the original byte-for-byte when there are no other edits, so * this path only runs when geometry/color/format actually changed. * - on → efficient codecs that shrink files at visually-equivalent quality: * mozjpeg for JPEG, palette quantization for PNG (in encodeToBuffer), * efficient lossy WebP/AVIF. */ function encode(pipeline: Sharp, output: ImageEditRecipe['output'], fast = false): Sharp { const compress = output.compress ?? false; const quality = output.quality ?? 80; switch (output.format) { case 'png': // Lossy PNG (palette quantization) is handled in encodeToBuffer; here // we always produce the lossless variant. `fast` trades a little size // accuracy for speed (used by the live estimate on large images). return pipeline.png({ compressionLevel: fast ? 6 : 9, effort: fast ? 1 : 10 }); case 'jpeg': // mozjpeg = trellis quantization + optimized Huffman + progressive. return pipeline.jpeg({ quality: compress ? quality : 95, mozjpeg: true }); case 'webp': return output.lossless || !compress ? pipeline.webp({ lossless: true, effort: fast ? 0 : 6 }) : pipeline.webp({ quality, effort: fast ? 0 : 6, smartSubsample: true }); case 'gif': return pipeline.gif({ dither: 1 }); case 'avif': // Off = preserve quality (near-lossless); on = the requested quality. // Keep the two clearly apart so toggling compression changes the size. // AVIF effort is the slowest knob — drop it hard for the estimate. return pipeline.avif({ quality: compress ? (output.quality ?? 80) : 92, effort: fast ? 0 : 4 }); default: { // Exhaustiveness guard — unreachable for valid recipes. const never: never = output.format; throw new Error(`Unsupported output format: ${String(never)}`); } } } /** * Apply a recipe to the image at `sourcePath` and return the encoded result. * Throws if the source is not a decodable image or the output exceeds the * configured file-size limit. */ export async function processImageEdit( sourcePath: string, recipe: ImageEditRecipe, fast = false ): Promise { const srcMeta = await sharp(sourcePath, { animated: true }).metadata(); if (!srcMeta.width || !srcMeta.height) { throw new Error('Source file is not a readable image'); } const frameCount = srcMeta.pages ?? 1; if (frameCount > MAX_ANIMATION_FRAMES) { throw new Error( `Cannot process animated image: ${frameCount} frames exceeds the limit of ${MAX_ANIMATION_FRAMES}` ); } // Nothing to do: not compressing, no edits, and the format is unchanged → // return the original bytes verbatim so an untouched file stays identical. const compress = recipe.output.compress ?? false; if (!compress && !hasEdits(recipe) && srcMeta.format === recipe.output.format) { const data = Buffer.from(await Bun.file(sourcePath).arrayBuffer()); validateFileSize(data.length); return { data, format: srcMeta.format ?? recipe.output.format, width: srcMeta.width, height: srcMeta.height, size: data.length }; } const animated = frameCount > 1; const rotate = recipe.rotate ?? 0; let pipeline: Sharp; if (animated) { // Single pass keeps every frame. sharp applies extract/resize per frame. let p = sharp(sourcePath, { animated: true }); if (rotate) p = p.rotate(rotate); if (recipe.flipV) p = p.flip(); if (recipe.flipH) p = p.flop(); if (recipe.crop) { p = p.extract(boundCrop(recipe.crop, srcMeta.width, srcMeta.height)); } if (recipe.resize) { p = p.resize(Math.round(recipe.resize.width), Math.round(recipe.resize.height), { fit: 'fill' }); } pipeline = applyColor(p, recipe); } else { // The browser decodes images with EXIF orientation applied, so the editor's // coordinates live in that space. EXIF-oriented dimensions are derived from // metadata (orientation 5–8 swap W/H) so the common no-rotation path never // has to fully decode a large image just to estimate its size. const exifSwap = (srcMeta.orientation ?? 1) >= 5; const baseW = exifSwap ? srcMeta.height : srcMeta.width; const baseH = exifSwap ? srcMeta.width : srcMeta.height; const quarterTurn = rotate === 90 || rotate === 270; const orientedWidth = quarterTurn ? baseH : baseW; const orientedHeight = quarterTurn ? baseW : baseH; const regions = recipe.regions ?? []; const annotations = recipe.annotations ?? []; // Decode the EXIF-auto-oriented source into a raw buffer. Only used when a // rotation/flip must be baked (so crop maps after rotation) or overlays are // composited — the plain path below stays lazy. const decodeOrientedRaw = async () => { const { data, info } = await sharp(sourcePath) .rotate() .ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }); return sharp(data, { raw: { width: info.width, height: info.height, channels: info.channels } }); }; if (regions.length > 0 || annotations.length > 0) { // Bake orientation + color first, then composite blur/pixelate regions // and freehand strokes in oriented space, then crop/resize/encode. let oriented = (await decodeOrientedRaw()).rotate(rotate); if (recipe.flipV) oriented = oriented.flip(); if (recipe.flipH) oriented = oriented.flop(); oriented = applyColor(oriented, recipe); const orientedBuf = await oriented.png().toBuffer(); // Composite layers must fit the *actual* baked buffer, which can differ // from the metadata-derived oriented size by a sub-pixel after EXIF // auto-orient + rotate. Measure the real dimensions and size every // layer against them, or sharp rejects them ("same dimensions or smaller"). const bakedMeta = await sharp(orientedBuf).metadata(); const ow = bakedMeta.width ?? orientedWidth; const oh = bakedMeta.height ?? orientedHeight; const composites: { input: Buffer; left: number; top: number }[] = []; for (const region of regions) { composites.push(await buildRegionComposite(orientedBuf, region, ow, oh)); } if (annotations.length > 0) { // Rasterize the overlay to an exact-size PNG first. Compositing a raw // SVG buffer can be rejected ("same dimensions or smaller") when // librsvg rounds the raster up by a sub-pixel versus the base. const svg = buildAnnotationSvg(annotations, ow, oh); const svgPng = await sharp(Buffer.from(svg)) .resize(ow, oh, { fit: 'fill' }) .png() .toBuffer(); composites.push({ input: svgPng, left: 0, top: 0 }); } // Flatten the composites into the base BEFORE cropping/resizing. sharp // orders pipeline ops internally (extract/resize run before composite), // so chaining .composite().extract() on one pipeline would crop the base // first and then reject the full-size overlay. Baking to a buffer pins // the overlay to the full oriented frame it was authored against. const compositedBuf = composites.length > 0 ? await sharp(orientedBuf).composite(composites).png().toBuffer() : orientedBuf; let p = sharp(compositedBuf); if (recipe.crop) { p = p.extract(boundCrop(recipe.crop, ow, oh)); } if (recipe.resize) { p = p.resize(Math.round(recipe.resize.width), Math.round(recipe.resize.height), { fit: 'fill' }); } // Color already baked above. pipeline = p; } else if (rotate || recipe.flipH || recipe.flipV) { // Bake EXIF + user orientation into a raw buffer so the crop coordinates // (expressed against the oriented preview) map exactly — sharp's single // pipeline does not honor rotate-before-extract call order. let pre = (await decodeOrientedRaw()).rotate(rotate); if (recipe.flipV) pre = pre.flip(); if (recipe.flipH) pre = pre.flop(); const { data, info } = await pre.raw().toBuffer({ resolveWithObject: true }); let p = sharp(data, { raw: { width: info.width, height: info.height, channels: info.channels } }); if (recipe.crop) { p = p.extract(boundCrop(recipe.crop, orientedWidth, orientedHeight)); } if (recipe.resize) { p = p.resize(Math.round(recipe.resize.width), Math.round(recipe.resize.height), { fit: 'fill' }); } pipeline = applyColor(p, recipe); } else { // No rotation/flip: stay lazy. `.rotate()` auto-orients via EXIF before // extract, so crop coordinates still map to the displayed image. let p = sharp(sourcePath).rotate(); if (recipe.crop) { p = p.extract(boundCrop(recipe.crop, orientedWidth, orientedHeight)); } if (recipe.resize) { p = p.resize(Math.round(recipe.resize.width), Math.round(recipe.resize.height), { fit: 'fill' }); } pipeline = applyColor(p, recipe); } } const { data, info } = await encodeToBuffer(pipeline, recipe.output, fast); // Guard against producing an output larger than the admin file-size limit. validateFileSize(data.length); return { data, format: info.format, width: info.width, height: info.height, size: data.length }; } /** * Encode to a Buffer. For lossy PNG we render both palette-quantized and * lossless variants and keep the smaller one — palette quantization shrinks * photographic PNGs dramatically but can *grow* smooth/continuous-tone images, * so "compress" must never increase the size. */ async function encodeToBuffer( pipeline: Sharp, output: ImageEditRecipe['output'], fast = false ): Promise<{ data: Buffer; info: { format: string; width: number; height: number } }> { if (output.format === 'png' && (output.compress ?? false) && !output.lossless) { const quality = output.quality ?? 80; // Estimate path: a single fast palette encode instead of the dual // palette/lossless comparison (which is too slow on large images). if (fast) { return pipeline .png({ palette: true, quality, dither: 1, effort: 1, compressionLevel: 6 }) .toBuffer({ resolveWithObject: true }); } const [palette, lossless] = await Promise.all([ pipeline .clone() .png({ palette: true, quality, dither: 1, effort: 10, compressionLevel: 9 }) .toBuffer({ resolveWithObject: true }), pipeline.clone().png({ compressionLevel: 9, effort: 10 }).toBuffer({ resolveWithObject: true }) ]); return palette.data.length <= lossless.data.length ? palette : lossless; } return encode(pipeline, output, fast).toBuffer({ resolveWithObject: true }); } /** Clamp a crop rectangle so it always sits within the image bounds. */ function boundCrop( crop: NonNullable, imgWidth: number, imgHeight: number ): { left: number; top: number; width: number; height: number } { const left = clampInt(crop.left, 0, Math.max(0, imgWidth - 1)); const top = clampInt(crop.top, 0, Math.max(0, imgHeight - 1)); const width = clampInt(crop.width, 1, imgWidth - left); const height = clampInt(crop.height, 1, imgHeight - top); return { left, top, width, height }; }