import { MAX_OUTPUT_DIMENSION } from "../config"; /** * The sharp surface av-club exposes, as data. * * Every operation is named what SHARP names it and takes SHARP's arguments, so sharp's own * documentation is the reference. This table is the single source of truth for three things that * would otherwise drift apart: * * 1. what the validator accepts * 2. what docs/SHARP.md says is accepted * 3. what is actually called * * Adding an operation is one entry here. * * ── Why a table rather than "pass the options through" ── * * sharp is memory-safe in the ways ffmpeg is not — it takes buffers and returns buffers, and has no * protocol handlers, no plugin loading and no filesystem syntax. Its danger is RESOURCE * EXHAUSTION rather than code execution: `resize(50000, 50000)` is an allocation request, `blur(10000)` * is a very long time. So every numeric parameter is bounded, and the bound is written next to the * parameter rather than checked somewhere else. */ export type Param = | { kind: "int"; min: number; max: number; note?: string } | { kind: "number"; min: number; max: number; note?: string } | { kind: "bool"; note?: string } | { kind: "enum"; values: readonly string[]; note?: string } | { kind: "color"; note?: string } | { kind: "numberArray"; minLength: number; maxLength: number; min: number; max: number; note?: string } | { kind: "assetId"; note?: string } | { kind: "string"; maxLength: number; note?: string }; export type OpSpec = { /** One line, used in docs/SHARP.md. */ describe: string; params: Record; /** Operations that take a bare value rather than an options object, e.g. `median(size)`. */ positional?: string; }; const DIM = MAX_OUTPUT_DIMENSION; /** sharp's constructor options. */ export const INPUT_OPTIONS: Record = { failOn: { kind: "enum", values: ["none", "truncated", "error", "warning"], note: "How strict to be about a damaged file. sharp's default is 'warning'." }, density: { kind: "number", min: 1, max: 2400, note: "DPI for vector input." }, pages: { kind: "int", min: -1, max: 1000, note: "Pages to read from a multi-page input. -1 for all." }, page: { kind: "int", min: 0, max: 100_000, note: "First page to read." }, animated: { kind: "bool", note: "Read all frames of an animated GIF/WebP. Equivalent to pages: -1." }, ignoreIcc: { kind: "bool", note: "Ignore any embedded ICC profile." }, autoOrient: { kind: "bool", note: "Apply EXIF orientation on load. See the note about rotate() in docs/SHARP.md." }, sequentialRead: { kind: "bool" }, level: { kind: "int", min: 0, max: 256, note: "Level to extract from a multi-level input." }, subifd: { kind: "int", min: -1, max: 100_000, note: "Subldeaf IFD to extract from an OME-TIFF." }, }; /** * The pipeline operations. * * `limitInputPixels` and `withMetadata` are DELIBERATELY ABSENT and must stay that way: * - limitInputPixels is the decompression-bomb guard and is set by the server, not the caller. * - withMetadata re-attaches EXIF, which is how a client's GPS coordinates get published. * See docs/SECURITY.md. */ export const OPERATIONS: Record = { // ── Resizing ─────────────────────────────────────────────────────────────────────────────── resize: { describe: "Resize to given dimensions.", params: { width: { kind: "int", min: 1, max: DIM }, height: { kind: "int", min: 1, max: DIM }, fit: { kind: "enum", values: ["cover", "contain", "fill", "inside", "outside"], note: "How the image fits the box. Default 'cover'." }, position: { kind: "enum", values: ["centre", "center", "top", "right top", "right", "right bottom", "bottom", "left bottom", "left", "left top", "north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest", "entropy", "attention"], note: "Which part survives when fit is 'cover'. 'attention' picks the busiest region." }, background: { kind: "color", note: "Fill colour when fit is 'contain'." }, kernel: { kind: "enum", values: ["nearest", "linear", "cubic", "mitchell", "lanczos2", "lanczos3"] }, withoutEnlargement: { kind: "bool", note: "Never scale UP. Almost always what you want — see docs/SHARP.md." }, withoutReduction: { kind: "bool", note: "Never scale DOWN." }, fastShrinkOnLoad: { kind: "bool" }, }, }, extend: { describe: "Add pixels to the edges.", params: { top: { kind: "int", min: 0, max: DIM }, bottom: { kind: "int", min: 0, max: DIM }, left: { kind: "int", min: 0, max: DIM }, right: { kind: "int", min: 0, max: DIM }, extendWith: { kind: "enum", values: ["background", "copy", "repeat", "mirror"] }, background: { kind: "color" }, }, }, extract: { describe: "Crop to a region.", params: { left: { kind: "int", min: 0, max: DIM }, top: { kind: "int", min: 0, max: DIM }, width: { kind: "int", min: 1, max: DIM }, height: { kind: "int", min: 1, max: DIM }, }, }, trim: { describe: "Trim uniform borders.", params: { background: { kind: "color" }, threshold: { kind: "number", min: 0, max: 255 }, lineArt: { kind: "bool" }, }, }, // ── Orientation ──────────────────────────────────────────────────────────────────────────── rotate: { describe: "Rotate. With no angle, applies the EXIF orientation — see docs/SHARP.md.", params: { angle: { kind: "number", min: -3600, max: 3600, note: "Multiples of 90 are lossless; anything else needs a background." }, background: { kind: "color" }, }, }, flip: { describe: "Mirror vertically.", params: { flip: { kind: "bool" } }, positional: "flip" }, flop: { describe: "Mirror horizontally.", params: { flop: { kind: "bool" } }, positional: "flop" }, // ── Pixel operations ─────────────────────────────────────────────────────────────────────── sharpen: { describe: "Sharpen.", params: { sigma: { kind: "number", min: 0.000001, max: 10 }, m1: { kind: "number", min: 0, max: 1_000_000 }, m2: { kind: "number", min: 0, max: 1_000_000 }, x1: { kind: "number", min: 0, max: 1_000_000 }, y2: { kind: "number", min: 0, max: 1_000_000 }, y3: { kind: "number", min: 0, max: 1_000_000 }, }, }, blur: { describe: "Gaussian blur.", params: { sigma: { kind: "number", min: 0.3, max: 1000 } }, positional: "sigma", }, median: { describe: "Median filter — good for noise.", params: { size: { kind: "int", min: 1, max: 1000 } }, positional: "size", }, dilate: { describe: "Morphological dilate.", params: { width: { kind: "int", min: 1, max: 1000 } }, positional: "width" }, erode: { describe: "Morphological erode.", params: { width: { kind: "int", min: 1, max: 1000 } }, positional: "width" }, flatten: { describe: "Composite transparency onto a background.", params: { background: { kind: "color" } } }, unflatten: { describe: "Make pure white pixels transparent.", params: {} }, gamma: { describe: "Apply gamma correction.", params: { gamma: { kind: "number", min: 1, max: 3 }, gammaOut: { kind: "number", min: 1, max: 3 } }, }, negate: { describe: "Invert.", params: { alpha: { kind: "bool", note: "Also invert the alpha channel." } } }, normalise: { describe: "Stretch the histogram to full range.", params: { lower: { kind: "number", min: 0, max: 99 }, upper: { kind: "number", min: 1, max: 100 } }, }, clahe: { describe: "Contrast-limited adaptive histogram equalisation.", params: { width: { kind: "int", min: 1, max: DIM }, height: { kind: "int", min: 1, max: DIM }, maxSlope: { kind: "int", min: 0, max: 100 }, }, }, convolve: { describe: "Apply a convolution kernel.", params: { width: { kind: "int", min: 1, max: 100 }, height: { kind: "int", min: 1, max: 100 }, kernel: { kind: "numberArray", minLength: 1, maxLength: 10_000, min: -1000, max: 1000 }, scale: { kind: "number", min: -10_000, max: 10_000 }, offset: { kind: "number", min: -10_000, max: 10_000 }, }, }, threshold: { describe: "Reduce to black and white at a cutoff.", params: { threshold: { kind: "int", min: 0, max: 255 }, greyscale: { kind: "bool" } }, }, linear: { describe: "Multiply and add: out = in * a + b.", params: { a: { kind: "numberArray", minLength: 1, maxLength: 4, min: -1000, max: 1000 }, b: { kind: "numberArray", minLength: 1, maxLength: 4, min: -1000, max: 1000 }, }, }, recomb: { describe: "Recombine channels with a 3x3 matrix.", params: { matrix: { kind: "numberArray", minLength: 9, maxLength: 9, min: -100, max: 100 } }, positional: "matrix", }, modulate: { describe: "Adjust brightness, saturation, hue and lightness.", params: { brightness: { kind: "number", min: 0, max: 100 }, saturation: { kind: "number", min: 0, max: 100 }, hue: { kind: "number", min: -3600, max: 3600, note: "Degrees." }, lightness: { kind: "number", min: -100, max: 100 }, }, }, // ── Colour ───────────────────────────────────────────────────────────────────────────────── tint: { describe: "Tint with a colour.", params: { color: { kind: "color" } }, positional: "color" }, greyscale: { describe: "Convert to greyscale.", params: { greyscale: { kind: "bool" } }, positional: "greyscale" }, toColourspace: { describe: "Convert to a colourspace.", params: { colourspace: { kind: "enum", values: ["srgb", "rgb", "cmyk", "lab", "b-w", "hsv", "scrgb", "lch", "grey16", "rgb16"] } }, positional: "colourspace", }, pipelineColourspace: { describe: "Set the colourspace operations are performed in.", params: { colourspace: { kind: "enum", values: ["srgb", "rgb", "cmyk", "lab", "b-w", "hsv", "scrgb", "lch", "grey16", "rgb16"] } }, positional: "colourspace", }, // ── Channels ─────────────────────────────────────────────────────────────────────────────── removeAlpha: { describe: "Drop the alpha channel.", params: {} }, ensureAlpha: { describe: "Add an alpha channel if absent.", params: { alpha: { kind: "number", min: 0, max: 1 } }, positional: "alpha" }, extractChannel: { describe: "Keep one channel.", params: { channel: { kind: "enum", values: ["red", "green", "blue", "alpha", "0", "1", "2", "3"] } }, positional: "channel", }, bandbool: { describe: "Boolean-combine all channels into one.", params: { operator: { kind: "enum", values: ["and", "or", "eor"] } }, positional: "operator", }, // ── Composite ────────────────────────────────────────────────────────────────────────────── // // `input` is an ASSET ID, not a path. That is what makes compositing safe to expose at all: // sharp's own composite() takes a filename, which would hand the caller arbitrary file reads. composite: { describe: "Overlay another asset. `input` is an asset id, never a path.", params: { input: { kind: "assetId", note: "The asset to overlay. Must already be uploaded." }, blend: { kind: "enum", values: ["clear", "source", "over", "in", "out", "atop", "dest", "dest-over", "dest-in", "dest-out", "dest-atop", "xor", "add", "saturate", "multiply", "screen", "overlay", "darken", "lighten", "colour-dodge", "color-dodge", "colour-burn", "color-burn", "hard-light", "soft-light", "difference", "exclusion"] }, gravity: { kind: "enum", values: ["centre", "center", "north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest"] }, top: { kind: "int", min: 0, max: DIM }, left: { kind: "int", min: 0, max: DIM }, tile: { kind: "bool" }, premultiplied: { kind: "bool" }, }, }, }; /** Output encoders, named as sharp names them, with sharp's own option names. */ export const OUTPUT_FORMATS: Record }> = { jpeg: { describe: "JPEG.", params: { quality: { kind: "int", min: 1, max: 100 }, progressive: { kind: "bool" }, chromaSubsampling: { kind: "enum", values: ["4:4:4", "4:2:2", "4:2:0"] }, optimiseCoding: { kind: "bool" }, mozjpeg: { kind: "bool", note: "Turns on a bundle of mozjpeg settings at once." }, trellisQuantisation: { kind: "bool" }, overshootDeringing: { kind: "bool" }, optimiseScans: { kind: "bool" }, }, }, png: { describe: "PNG.", params: { progressive: { kind: "bool" }, compressionLevel: { kind: "int", min: 0, max: 9 }, adaptiveFiltering: { kind: "bool" }, palette: { kind: "bool", note: "Quantise to a palette — much smaller for flat graphics." }, quality: { kind: "int", min: 0, max: 100 }, effort: { kind: "int", min: 1, max: 10 }, colours: { kind: "int", min: 2, max: 256 }, dither: { kind: "number", min: 0, max: 1 }, }, }, webp: { describe: "WebP.", params: { quality: { kind: "int", min: 1, max: 100 }, alphaQuality: { kind: "int", min: 0, max: 100 }, lossless: { kind: "bool" }, nearLossless: { kind: "bool" }, smartSubsample: { kind: "bool" }, preset: { kind: "enum", values: ["default", "photo", "picture", "drawing", "icon", "text"] }, effort: { kind: "int", min: 0, max: 6 }, loop: { kind: "int", min: 0, max: 100_000 }, minSize: { kind: "bool" }, mixed: { kind: "bool" }, }, }, avif: { describe: "AVIF. Smaller than WebP at the same quality, slower to encode.", params: { quality: { kind: "int", min: 1, max: 100 }, lossless: { kind: "bool" }, effort: { kind: "int", min: 0, max: 9, note: "Higher is smaller and much slower." }, chromaSubsampling: { kind: "enum", values: ["4:4:4", "4:2:0"] }, bitdepth: { kind: "enum", values: ["8", "10", "12"] }, }, }, tiff: { describe: "TIFF.", params: { quality: { kind: "int", min: 1, max: 100 }, compression: { kind: "enum", values: ["none", "jpeg", "deflate", "packbits", "ccittfax4", "lzw", "webp", "zstd", "jp2k"] }, predictor: { kind: "enum", values: ["none", "horizontal", "float"] }, pyramid: { kind: "bool" }, tile: { kind: "bool" }, tileWidth: { kind: "int", min: 1, max: DIM }, tileHeight: { kind: "int", min: 1, max: DIM }, xres: { kind: "number", min: 0.01, max: 100_000 }, yres: { kind: "number", min: 0.01, max: 100_000 }, resolutionUnit: { kind: "enum", values: ["inch", "cm"] }, bitdepth: { kind: "enum", values: ["1", "2", "4", "8"] }, }, }, gif: { describe: "GIF.", params: { reuse: { kind: "bool" }, progressive: { kind: "bool" }, colours: { kind: "int", min: 2, max: 256 }, effort: { kind: "int", min: 1, max: 10 }, dither: { kind: "number", min: 0, max: 1 }, interFrameMaxError: { kind: "number", min: 0, max: 32 }, interPaletteMaxError: { kind: "number", min: 0, max: 256 }, loop: { kind: "int", min: 0, max: 100_000 }, }, }, jp2: { describe: "JPEG 2000.", params: { quality: { kind: "int", min: 1, max: 100 }, lossless: { kind: "bool" }, tileWidth: { kind: "int", min: 1, max: DIM }, tileHeight: { kind: "int", min: 1, max: DIM }, chromaSubsampling: { kind: "enum", values: ["4:4:4", "4:2:2", "4:2:0"] }, }, }, };