av-club keeps doing exactly what it did — sharp, ffmpeg and ffprobe behind one API, and the invariant that the caller never names a path. What changes is how it is reached and who decides who may reach it. `src/` is `sidecar/`, because that is the name the platform looks for: `sidecar/index.ts` present means "this plugin ships a process", and presence is the declaration for everything structural here. `manifest.ts` at the root is the other half of that rule. api/router.ts is a proxy and nothing else. the platform authenticates, checks the `av-club` permission, and forwards — so the sidecar now accepts a request carrying `X-Officer-User` and no longer demands its own bearer token from callers who came that way. it binds LOOPBACK to make that trustworthy, which is the same boundary caldav's DAV door already relies on. AV_CLUB_TOKEN stays, and still guards anything calling the port directly: fofos/platform is the stated first consumer and is not Officer. THREE THINGS HAD TO GO, and each one broke it in a way worth writing down. package.json and bun.lock: a package.json inside a plugin is a package boundary, so bun stops walking up for the platform's tsconfig and `@@/sidecar/connect` stops resolving. the sidecar died on its first import, fifteen times. npm dependencies go in the manifest instead, where npm-deps.ts intersects them across plugins into one plugins/node_modules — which is also how this surfaced a real conflict: sharp ^0.33.5 (atelier) and ^0.35.3 here do not overlap, since a 0.x caret pins the minor. atelier moved up; it calls one method. tsconfig.json: same failure one layer earlier, shadowing the platform's `@@/*` paths so api/router.ts would not mount. no other plugin has one. no screens, deliberately. this is an API and a body of documentation; its callers are a commander watching a folder, an agent, or curl. a dock tile would advertise a page with nothing on it. verified through /api/av-club with an agent key: a 3000x2000 jpeg uploaded, probed, and resized to a 1200x800 avif — 166 KB to 1 KB — without the caller ever holding av-club's own token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
341 lines
16 KiB
TypeScript
341 lines
16 KiB
TypeScript
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<string, Param>;
|
|
/** 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<string, Param> = {
|
|
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<string, OpSpec> = {
|
|
// ── 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<string, { describe: string; params: Record<string, Param> }> = {
|
|
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"] },
|
|
},
|
|
},
|
|
};
|