become an Officer plugin
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
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { join } from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { JOB_TIMEOUT_MS } from "../config";
|
||||
import { unprocessable } from "../errors";
|
||||
|
||||
/**
|
||||
* Getting HEIC into something sharp can actually decode.
|
||||
*
|
||||
* ── Why this exists ──
|
||||
*
|
||||
* sharp ships its own libvips and libheif, built without the libde265 plugin, so it can parse a
|
||||
* HEIC's header — dimensions come back fine — and then fails on the pixels with "bad seek", which
|
||||
* reads like a corrupt file and is not one. Every photograph an iPhone takes is HEVC-coded HEIF, so
|
||||
* this is not an edge case: it is most of what a client will upload from a phone.
|
||||
*
|
||||
* ImageMagick links the SYSTEM libheif, which does have the plugin (see docs/TOOLING.md), so it
|
||||
* decodes them. This converts through it and hands sharp a PNG.
|
||||
*
|
||||
* PNG rather than JPEG for the intermediate: it is lossless, so the one real encode is the final one
|
||||
* and the derivative is not a copy of a copy.
|
||||
*/
|
||||
|
||||
/** Formats sharp reports but cannot decode here. Extend only with evidence, not suspicion. */
|
||||
const NEEDS_CONVERSION = new Set(["heif", "heic", "avif-sequence"]);
|
||||
|
||||
export type Decodable = {
|
||||
path: string;
|
||||
/** Call when finished. Removes the intermediate, if there was one. */
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function decodableInput(path: string, format: string): Promise<Decodable> {
|
||||
if (!NEEDS_CONVERSION.has(format.toLowerCase())) {
|
||||
return { path, cleanup: async () => {} };
|
||||
}
|
||||
|
||||
const workDir = await mkdtemp(join(tmpdir(), "avclub-heif-"));
|
||||
const converted = join(workDir, "decoded.png");
|
||||
|
||||
// ImageMagick 6 on Ubuntu 24.04, so `convert` — there is no `magick`. Spawned as an argv array,
|
||||
// so the path is data rather than something a shell parses.
|
||||
//
|
||||
// -auto-orient applies the EXIF rotation while the metadata still exists; the PNG carries none, so
|
||||
// this is the last chance to get a phone photograph the right way up.
|
||||
const proc = Bun.spawn(["convert", path, "-auto-orient", converted], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "ignore",
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => proc.kill(), JOB_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const [stderr, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
|
||||
|
||||
if (code !== 0 || !(await Bun.file(converted).exists())) {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
throw unprocessable(
|
||||
`Could not read that HEIC: ${stderr.trim().split("\n").at(-1) ?? "conversion failed"}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
path: converted,
|
||||
cleanup: () => rm(workDir, { recursive: true, force: true }),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import sharp from "sharp";
|
||||
import { MAX_INPUT_PIXELS } from "../config";
|
||||
import { invalid, unprocessable } from "../errors";
|
||||
import { contentPath, putAsset, readAsset, type Asset } from "../assets/store";
|
||||
import { INPUT_OPTIONS, OPERATIONS, OUTPUT_FORMATS } from "./vocabulary";
|
||||
import { validateObject } from "./validate";
|
||||
import { decodableInput } from "./decodable";
|
||||
|
||||
export type SharpRequest = {
|
||||
input?: unknown;
|
||||
options?: unknown;
|
||||
pipeline?: unknown;
|
||||
output?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run a validated sharp pipeline and store the result as a new asset.
|
||||
*
|
||||
* The input asset is never modified — every operation produces a new asset. That is what makes it
|
||||
* safe to re-run a pipeline with different settings six months from now, which is the whole reason
|
||||
* originals are kept (docs/PIPELINE.md: "The original is never destroyed").
|
||||
*/
|
||||
export async function runSharp(body: SharpRequest): Promise<Asset> {
|
||||
if (typeof body.input !== "string") {
|
||||
throw invalid("input must be an asset id", "input");
|
||||
}
|
||||
|
||||
const source = await readAsset(body.input);
|
||||
if (source.kind !== "image") {
|
||||
throw invalid(`Asset ${source.id} is ${source.kind}, not an image. Use /v1/ffmpeg for video and audio.`, "input");
|
||||
}
|
||||
|
||||
const inputOptions = validateObject(INPUT_OPTIONS, body.options, "options");
|
||||
|
||||
// limitInputPixels is set HERE and cannot be supplied by the caller: it is the decompression-bomb
|
||||
// guard, and a caller who could raise it could turn a 1 KB PNG into a 40 GB allocation.
|
||||
// HEIC has to go through ImageMagick first — sharp's bundled libheif cannot decode it. A no-op
|
||||
// for every other format. See decodable.ts.
|
||||
const decoded = await decodableInput(contentPath(source.id), source.format);
|
||||
|
||||
let image = sharp(decoded.path, {
|
||||
...inputOptions,
|
||||
limitInputPixels: MAX_INPUT_PIXELS,
|
||||
// `unlimited` is what gets past libheif's iref-reference cap, which iPhone HEICs carrying
|
||||
// burst or depth data routinely exceed — sharp reports it as "corrupt header", which it is not.
|
||||
// It loosens libvips' own buffer guards, so `limitInputPixels` is passed EXPLICITLY alongside:
|
||||
// verified that the pixel limit still rejects a decompression bomb with unlimited set.
|
||||
unlimited: true,
|
||||
});
|
||||
|
||||
// ── The pipeline ─────────────────────────────────────────────────────────────────────────────
|
||||
const steps = body.pipeline ?? [];
|
||||
if (!Array.isArray(steps)) throw invalid("pipeline must be an array of operations", "pipeline");
|
||||
|
||||
for (const [i, step] of steps.entries()) {
|
||||
const at = `pipeline[${i}]`;
|
||||
if (!step || typeof step !== "object" || Array.isArray(step)) {
|
||||
throw invalid(`${at} must be an object like { "resize": { "width": 800 } }`, at);
|
||||
}
|
||||
|
||||
const entries = Object.entries(step as Record<string, unknown>);
|
||||
if (entries.length !== 1) {
|
||||
throw invalid(`${at} must name exactly one operation, got ${entries.length}`, at);
|
||||
}
|
||||
|
||||
const [name, rawArgs] = entries[0]!;
|
||||
const spec = OPERATIONS[name];
|
||||
if (!spec) {
|
||||
throw invalid(
|
||||
`Unknown operation ${JSON.stringify(name)}. See docs/SHARP.md for the full list.`,
|
||||
`${at}.${name}`,
|
||||
);
|
||||
}
|
||||
|
||||
const args = validateObject(spec.params, rawArgs, `${at}.${name}`);
|
||||
const method = (image as unknown as Record<string, (...a: unknown[]) => typeof image>)[name];
|
||||
if (typeof method !== "function") {
|
||||
// The vocabulary named something sharp does not have — our bug, not the caller's.
|
||||
throw new Error(`vocabulary lists ${name}, but sharp has no such method`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Operations sharp declares positionally — median(size), blur(sigma) — are called that way.
|
||||
// Everything else takes an options object.
|
||||
if (spec.positional && spec.positional in args) {
|
||||
const positional = args[spec.positional];
|
||||
const rest = { ...args };
|
||||
delete rest[spec.positional];
|
||||
image = Object.keys(rest).length
|
||||
? method.call(image, positional, rest)
|
||||
: method.call(image, positional);
|
||||
} else if (Object.keys(args).length === 0) {
|
||||
image = method.call(image);
|
||||
} else if (name === "composite") {
|
||||
// composite() takes an ARRAY, and its `input` is an asset id that only the store may turn
|
||||
// into a path. This is the one operation that reads a second file, and it is the reason
|
||||
// `assetId` exists as a parameter kind.
|
||||
const overlay = await readAsset(args.input as string);
|
||||
if (overlay.kind !== "image") {
|
||||
throw invalid(`composite input ${overlay.id} is ${overlay.kind}, not an image`, `${at}.composite.input`);
|
||||
}
|
||||
const { input: _dropped, ...placement } = args;
|
||||
image = image.composite([{ input: contentPath(overlay.id), ...placement } as never]);
|
||||
} else {
|
||||
image = method.call(image, args);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && !("type" in error)) {
|
||||
throw invalid(`${name} failed: ${error.message}`, `${at}.${name}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────────────────────────────────────
|
||||
const output = (body.output ?? {}) as Record<string, unknown>;
|
||||
if (typeof output !== "object" || Array.isArray(output)) {
|
||||
throw invalid("output must be an object", "output");
|
||||
}
|
||||
|
||||
const format = output.format ?? "webp";
|
||||
if (typeof format !== "string" || !OUTPUT_FORMATS[format]) {
|
||||
throw invalid(
|
||||
`output.format must be one of: ${Object.keys(OUTPUT_FORMATS).join(", ")}`,
|
||||
"output.format",
|
||||
);
|
||||
}
|
||||
|
||||
const encoderOptions = validateObject(OUTPUT_FORMATS[format]!.params, output.options, "output.options");
|
||||
const encode = (image as unknown as Record<string, (o: unknown) => typeof image>)[format]!;
|
||||
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = await encode.call(image, encoderOptions).toBuffer();
|
||||
} catch (error) {
|
||||
await decoded.cleanup();
|
||||
// A failure here is usually the source file, not the request — a truncated JPEG, a PNG whose
|
||||
// declared dimensions disagree with its data.
|
||||
throw unprocessable(
|
||||
`Could not process that image: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await decoded.cleanup();
|
||||
return putAsset(buffer);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { invalid } from "../errors";
|
||||
import type { Param } from "./vocabulary";
|
||||
|
||||
/**
|
||||
* Validate caller-supplied values against the vocabulary table.
|
||||
*
|
||||
* Default-deny throughout: an unknown operation, an unknown parameter, or a value of the wrong shape
|
||||
* is refused. Nothing is coerced and nothing is silently dropped — a typo'd parameter that was
|
||||
* quietly ignored would look exactly like an option that does not work.
|
||||
*
|
||||
* Error messages name the exact path (`pipeline[1].resize.width`) and say what was expected, because
|
||||
* the thing on the other end is usually relaying them to a person.
|
||||
*/
|
||||
|
||||
const ID_PATTERN = /^as_[0-9a-f]{32}$/;
|
||||
|
||||
/** sharp accepts a CSS colour string or {r,g,b,alpha}. Both are data — neither can reference a file. */
|
||||
function validateColor(value: unknown, path: string): unknown {
|
||||
if (typeof value === "string") {
|
||||
if (value.length > 64) throw invalid("Colour string is too long", path);
|
||||
return value;
|
||||
}
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!["r", "g", "b", "alpha"].includes(key)) {
|
||||
throw invalid(`Unknown colour component ${JSON.stringify(key)}. Expected r, g, b or alpha.`, path);
|
||||
}
|
||||
const component = obj[key];
|
||||
if (typeof component !== "number" || !Number.isFinite(component)) {
|
||||
throw invalid(`Colour component ${key} must be a number`, `${path}.${key}`);
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
throw invalid("Expected a colour: a CSS string like '#ffffff', or {r, g, b, alpha}", path);
|
||||
}
|
||||
|
||||
export function validateValue(spec: Param, value: unknown, path: string): unknown {
|
||||
switch (spec.kind) {
|
||||
case "int": {
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) {
|
||||
throw invalid(`${path} must be an integer`, path);
|
||||
}
|
||||
if (value < spec.min || value > spec.max) {
|
||||
throw invalid(`${path} must be between ${spec.min} and ${spec.max}, got ${value}`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "number": {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw invalid(`${path} must be a number`, path);
|
||||
}
|
||||
if (value < spec.min || value > spec.max) {
|
||||
throw invalid(`${path} must be between ${spec.min} and ${spec.max}, got ${value}`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "bool": {
|
||||
if (typeof value !== "boolean") throw invalid(`${path} must be true or false`, path);
|
||||
return value;
|
||||
}
|
||||
case "enum": {
|
||||
if (typeof value !== "string" || !spec.values.includes(value)) {
|
||||
throw invalid(`${path} must be one of: ${spec.values.join(", ")}`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "color":
|
||||
return validateColor(value, path);
|
||||
case "numberArray": {
|
||||
if (!Array.isArray(value)) throw invalid(`${path} must be an array of numbers`, path);
|
||||
if (value.length < spec.minLength || value.length > spec.maxLength) {
|
||||
throw invalid(
|
||||
`${path} must have between ${spec.minLength} and ${spec.maxLength} entries, got ${value.length}`,
|
||||
path,
|
||||
);
|
||||
}
|
||||
value.forEach((entry, i) => {
|
||||
if (typeof entry !== "number" || !Number.isFinite(entry)) {
|
||||
throw invalid(`${path}[${i}] must be a number`, `${path}[${i}]`);
|
||||
}
|
||||
if (entry < spec.min || entry > spec.max) {
|
||||
throw invalid(`${path}[${i}] must be between ${spec.min} and ${spec.max}`, `${path}[${i}]`);
|
||||
}
|
||||
});
|
||||
return value;
|
||||
}
|
||||
case "assetId": {
|
||||
// The ONLY way a caller may reference another file. Checked here against the same pattern the
|
||||
// store issues, then resolved to a path by the store and nowhere else. See docs/SECURITY.md.
|
||||
if (typeof value !== "string" || !ID_PATTERN.test(value)) {
|
||||
throw invalid(`${path} must be an asset id (as_ followed by 32 hex characters)`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "string": {
|
||||
if (typeof value !== "string") throw invalid(`${path} must be a string`, path);
|
||||
if (value.length > spec.maxLength) {
|
||||
throw invalid(`${path} must be at most ${spec.maxLength} characters`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate an options object against a table of parameter specs. Unknown keys are refused. */
|
||||
export function validateObject(
|
||||
params: Record<string, Param>,
|
||||
value: unknown,
|
||||
path: string,
|
||||
): Record<string, unknown> {
|
||||
if (value === undefined || value === null) return {};
|
||||
if (typeof value !== "object" || Array.isArray(value)) {
|
||||
throw invalid(`${path} must be an object`, path);
|
||||
}
|
||||
|
||||
const input = value as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, raw] of Object.entries(input)) {
|
||||
const spec = params[key];
|
||||
if (!spec) {
|
||||
const known = Object.keys(params);
|
||||
throw invalid(
|
||||
known.length
|
||||
? `Unknown option ${JSON.stringify(key)}. Accepted here: ${known.join(", ")}`
|
||||
: `${path} takes no options, got ${JSON.stringify(key)}`,
|
||||
`${path}.${key}`,
|
||||
);
|
||||
}
|
||||
if (raw === undefined) continue;
|
||||
out[key] = validateValue(spec, raw, `${path}.${key}`);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
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"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user