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,156 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { ASSETS_DIR, MAX_INPUT_PIXELS } from "../config";
|
||||
import { invalid, notFound, unprocessable } from "../errors";
|
||||
import { ffprobe } from "../probe/ffprobe";
|
||||
|
||||
/**
|
||||
* The asset store — and the place docs/SECURITY.md's central invariant is enforced.
|
||||
*
|
||||
* A caller never supplies a path. They supply an ID we issued, and this module is the only thing
|
||||
* that turns an ID into a path. Everything downstream receives a path this module produced.
|
||||
*/
|
||||
|
||||
export type AssetKind = "image" | "video" | "audio";
|
||||
|
||||
export type Asset = {
|
||||
id: string;
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
kind: AssetKind;
|
||||
format: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
durationSeconds?: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/** `as_` plus 32 lowercase hex. Anchored, fixed length, no separators — nothing here can traverse. */
|
||||
const ID_PATTERN = /^as_[0-9a-f]{32}$/;
|
||||
|
||||
export function newAssetId(): string {
|
||||
return `as_${crypto.randomUUID().replaceAll("-", "")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an ID to its directory, or throw.
|
||||
*
|
||||
* Two checks, deliberately, because they answer different questions:
|
||||
*
|
||||
* 1. Does the ID match the pattern we issue? — about the caller's INTENT
|
||||
* 2. Is the resolved path still inside the store? — about what actually HAPPENED
|
||||
*
|
||||
* The second is not redundant. It is the one that still holds if the pattern is ever loosened, if
|
||||
* ASSETS_DIR contains a symlink, or if some future refactor builds the path differently. Cheap, and
|
||||
* the failure it prevents is "read any file on the machine".
|
||||
*/
|
||||
function assetDir(id: string): string {
|
||||
if (!ID_PATTERN.test(id)) {
|
||||
throw invalid(`Not a valid asset id: ${JSON.stringify(id.slice(0, 64))}`, "id");
|
||||
}
|
||||
|
||||
const dir = resolve(join(ASSETS_DIR, id));
|
||||
if (dir !== join(ASSETS_DIR, id) || !dir.startsWith(ASSETS_DIR + sep)) {
|
||||
throw invalid("Not a valid asset id", "id");
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
export const contentPath = (id: string): string => join(assetDir(id), "content");
|
||||
const metaPath = (id: string): string => join(assetDir(id), "meta.json");
|
||||
|
||||
export async function readAsset(id: string): Promise<Asset> {
|
||||
const file = Bun.file(metaPath(id));
|
||||
if (!(await file.exists())) throw notFound();
|
||||
return (await file.json()) as Asset;
|
||||
}
|
||||
|
||||
export async function assetExists(id: string): Promise<boolean> {
|
||||
return Bun.file(metaPath(id)).exists();
|
||||
}
|
||||
|
||||
export async function deleteAsset(id: string): Promise<void> {
|
||||
await rm(assetDir(id), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify a file by INSPECTING IT, never from its name or the Content-Type a browser attached.
|
||||
*
|
||||
* Order matters: ffprobe will happily report a PNG as a video stream in an `image2` container, so
|
||||
* sharp is asked first and images are settled before ffprobe is consulted at all.
|
||||
*/
|
||||
async function identify(path: string): Promise<Pick<Asset, "kind" | "format" | "width" | "height" | "durationSeconds">> {
|
||||
try {
|
||||
const meta = await sharp(path, {
|
||||
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,
|
||||
}).metadata();
|
||||
if (meta.format) {
|
||||
return { kind: "image", format: meta.format, width: meta.width, height: meta.height };
|
||||
}
|
||||
} catch {
|
||||
// Not an image sharp can read. Fall through — it may well be a video.
|
||||
}
|
||||
|
||||
const probe = await ffprobe(path).catch(() => null);
|
||||
const streams = probe?.streams ?? [];
|
||||
|
||||
const video = streams.find((s) => s.codec_type === "video");
|
||||
const audio = streams.find((s) => s.codec_type === "audio");
|
||||
const duration = probe?.format?.duration ? Number(probe.format.duration) : undefined;
|
||||
const format = probe?.format?.format_name?.split(",")[0] ?? "unknown";
|
||||
|
||||
if (video) {
|
||||
return {
|
||||
kind: "video",
|
||||
format,
|
||||
width: typeof video.width === "number" ? video.width : undefined,
|
||||
height: typeof video.height === "number" ? video.height : undefined,
|
||||
durationSeconds: duration,
|
||||
};
|
||||
}
|
||||
if (audio) return { kind: "audio", format, durationSeconds: duration };
|
||||
|
||||
throw unprocessable(
|
||||
"That file could not be read as an image, video or audio file. It may be corrupt, or it may not be media at all.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store bytes and return the asset.
|
||||
*
|
||||
* The ID is issued here, so a caller can never influence where a file lands. Identification runs
|
||||
* AFTER the write because both sharp and ffprobe want a path — a file that turns out not to be
|
||||
* media is written and then removed rather than being buffered twice.
|
||||
*/
|
||||
export async function putAsset(bytes: ArrayBuffer | Uint8Array): Promise<Asset> {
|
||||
const id = newAssetId();
|
||||
const path = contentPath(id);
|
||||
|
||||
await Bun.write(path, bytes);
|
||||
|
||||
try {
|
||||
const identified = await identify(path);
|
||||
const buffer = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
|
||||
const asset: Asset = {
|
||||
id,
|
||||
bytes: buffer.byteLength,
|
||||
sha256: new Bun.CryptoHasher("sha256").update(buffer).digest("hex"),
|
||||
createdAt: new Date().toISOString(),
|
||||
...identified,
|
||||
};
|
||||
|
||||
await Bun.write(metaPath(id), JSON.stringify(asset, null, 2));
|
||||
return asset;
|
||||
} catch (error) {
|
||||
// Do not leave the bytes of something we refused sitting in the store.
|
||||
await deleteAsset(id).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Configuration, read once at boot.
|
||||
*
|
||||
* Every limit here exists because its absence is a denial of service — see docs/SECURITY.md,
|
||||
* "Layer 5 — bounded work". They are configurable so a deployment can tighten them, not so a
|
||||
* deployment can remove them: each has a floor enforced below.
|
||||
*/
|
||||
|
||||
function num(key: string, fallback: number, min: number): number {
|
||||
const raw = process.env[key];
|
||||
if (raw === undefined) return fallback;
|
||||
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error(`${key} must be a number, got ${JSON.stringify(raw)}`);
|
||||
}
|
||||
if (value < min) {
|
||||
throw new Error(`${key} must be at least ${min}, got ${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const PORT = num("AV_CLUB_PORT", 3020, 1);
|
||||
|
||||
/**
|
||||
* Where assets live. Outside the repo by default, and deliberately not under any directory a deploy
|
||||
* would replace — losing every stored asset because the working tree was refreshed is the kind of
|
||||
* failure that is only noticed later, by which point the originals are gone.
|
||||
*/
|
||||
export const ASSETS_DIR = resolve(process.env.AV_CLUB_ASSETS_DIR ?? `${process.env.HOME}/.local/share/av-club/assets`);
|
||||
|
||||
/** Multipart bodies are buffered whole. Without a cap, one request is an out-of-memory. */
|
||||
export const MAX_UPLOAD_BYTES = num("AV_CLUB_MAX_UPLOAD_BYTES", 512 * 1024 * 1024, 1024);
|
||||
|
||||
/** A crafted file can make an encoder run effectively forever. The process is killed at this point. */
|
||||
export const JOB_TIMEOUT_MS = num("AV_CLUB_JOB_TIMEOUT_MS", 120_000, 1_000);
|
||||
|
||||
/**
|
||||
* Decompression bombs: a 1 KB PNG can expand to tens of gigabytes. This is sharp's own
|
||||
* `limitInputPixels`, defaulted to its documented default (0x3FFF × 0x3FFF) rather than to `false`.
|
||||
*/
|
||||
export const MAX_INPUT_PIXELS = num("AV_CLUB_MAX_INPUT_PIXELS", 0x3fff * 0x3fff, 1);
|
||||
|
||||
/** `resize(50000, 50000)` is an allocation request, not an image. */
|
||||
export const MAX_OUTPUT_DIMENSION = num("AV_CLUB_MAX_OUTPUT_DIMENSION", 16_383, 1);
|
||||
|
||||
/** ffmpeg will take every core it is given. Two callers should not be able to take the box down. */
|
||||
export const MAX_CONCURRENT_JOBS = num("AV_CLUB_MAX_CONCURRENT_JOBS", 2, 1);
|
||||
|
||||
/**
|
||||
* The bearer token every request must carry.
|
||||
*
|
||||
* A FUNCTION, not a constant evaluated at import. The limits above are plain data that anything may
|
||||
* read — the doc generator imports the sharp vocabulary, which imports MAX_OUTPUT_DIMENSION from
|
||||
* here — and a module-scope throw meant that generating documentation required a production secret.
|
||||
*
|
||||
* The check itself still happens at boot: server.ts calls this before it listens, so a missing token
|
||||
* is a refusal to start rather than a service that quietly accepts everyone. There is no
|
||||
* "unauthenticated by default" mode; this process runs decoders over untrusted bytes.
|
||||
*/
|
||||
export function requireToken(): string {
|
||||
const value = process.env.AV_CLUB_TOKEN;
|
||||
if (!value || value.length < 16) {
|
||||
throw new Error(
|
||||
"AV_CLUB_TOKEN must be set and at least 16 characters. Generate one with: openssl rand -hex 32",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* One error envelope for every failure, because a client parses failures the same way it parses
|
||||
* successes. A route that answers with a bare string does not return the wrong shape — it breaks
|
||||
* the parse. `error` is always an object.
|
||||
*/
|
||||
|
||||
export const ERROR_STATUS = {
|
||||
invalid_request: 400,
|
||||
unauthorized: 401,
|
||||
not_found: 404,
|
||||
payload_too_large: 413,
|
||||
unprocessable: 422,
|
||||
timeout: 504,
|
||||
server_error: 500,
|
||||
} as const;
|
||||
|
||||
export type ErrorType = keyof typeof ERROR_STATUS;
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly type: ErrorType,
|
||||
message: string,
|
||||
/** Dotted path to the offending field, e.g. `pipeline[1].resize.width`. */
|
||||
readonly param?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
|
||||
toResponse(): Response {
|
||||
return Response.json(
|
||||
{ error: { message: this.message, type: this.type, param: this.param ?? null } },
|
||||
{ status: ERROR_STATUS[this.type] },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const invalid = (message: string, param?: string) => new ApiError("invalid_request", message, param);
|
||||
export const notFound = (message = "No such asset") => new ApiError("not_found", message);
|
||||
export const unprocessable = (message: string, param?: string) => new ApiError("unprocessable", message, param);
|
||||
|
||||
/**
|
||||
* Turn anything thrown into a response.
|
||||
*
|
||||
* An unrecognised error is logged and answered with a generic 500 — the message may name a path or
|
||||
* quote file contents, and neither belongs in a response body.
|
||||
*/
|
||||
export function toErrorResponse(error: unknown): Response {
|
||||
if (error instanceof ApiError) return error.toResponse();
|
||||
|
||||
console.error("[av-club] unhandled:", error);
|
||||
return Response.json(
|
||||
{ error: { message: "Internal Server Error", type: "server_error", param: null } },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { join } from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { JOB_TIMEOUT_MS } from "../config";
|
||||
import { invalid, unprocessable, ApiError } from "../errors";
|
||||
import { contentPath, putAsset, readAsset, type Asset } from "../assets/store";
|
||||
import {
|
||||
AUDIO_FILTERS,
|
||||
FORMATS,
|
||||
INPUT_OPTIONS,
|
||||
OUTPUT_OPTIONS,
|
||||
PRESETS,
|
||||
VIDEO_FILTERS,
|
||||
type FfmpegParam,
|
||||
} from "./vocabulary";
|
||||
|
||||
/**
|
||||
* Running ffmpeg.
|
||||
*
|
||||
* Everything the caller supplies is a NAME or a NUMBER from the vocabulary. Paths are constructed
|
||||
* here from an asset id, and the process is spawned with an argv array so there is no shell to quote
|
||||
* for. See docs/SECURITY.md.
|
||||
*/
|
||||
|
||||
export type FfmpegRequest = {
|
||||
input?: unknown;
|
||||
preset?: unknown;
|
||||
inputOptions?: unknown;
|
||||
outputOptions?: unknown;
|
||||
filters?: unknown;
|
||||
format?: unknown;
|
||||
};
|
||||
|
||||
const BITRATE = /^[0-9]+(\.[0-9]+)?[kKmM]?$/;
|
||||
|
||||
function value(spec: FfmpegParam, raw: unknown, path: string): string {
|
||||
switch (spec.kind) {
|
||||
case "int":
|
||||
if (typeof raw !== "number" || !Number.isInteger(raw)) throw invalid(`${path} must be an integer`, path);
|
||||
if (raw < spec.min || raw > spec.max) throw invalid(`${path} must be between ${spec.min} and ${spec.max}`, path);
|
||||
return String(raw);
|
||||
case "number":
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) throw invalid(`${path} must be a number`, path);
|
||||
if (raw < spec.min || raw > spec.max) throw invalid(`${path} must be between ${spec.min} and ${spec.max}`, path);
|
||||
return String(raw);
|
||||
case "enum":
|
||||
if (typeof raw !== "string" || !spec.values.includes(raw)) {
|
||||
throw invalid(`${path} must be one of: ${spec.values.join(", ")}`, path);
|
||||
}
|
||||
return raw;
|
||||
case "bitrate":
|
||||
if (typeof raw !== "string" || !BITRATE.test(raw)) {
|
||||
throw invalid(`${path} must be a bitrate like "2M" or "128k"`, path);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function options(table: Record<string, FfmpegParam>, raw: unknown, path: string): string[] {
|
||||
if (raw === undefined || raw === null) return [];
|
||||
if (typeof raw !== "object" || Array.isArray(raw)) throw invalid(`${path} must be an object`, path);
|
||||
|
||||
const args: string[] = [];
|
||||
for (const [key, entry] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const spec = table[key];
|
||||
if (!spec) {
|
||||
throw invalid(`Unknown option ${JSON.stringify(key)}. Accepted here: ${Object.keys(table).join(", ")}`, `${path}.${key}`);
|
||||
}
|
||||
args.push(`-${key}`, value(spec, entry, `${path}.${key}`));
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter chain, built from structured objects rather than parsed from a string.
|
||||
*
|
||||
* ffmpeg's filter syntax has its own escaping rules, and parsing it well enough to validate it is
|
||||
* harder than accepting `{ name: args }`. The names and parameters are ffmpeg's; only the
|
||||
* punctuation is ours — and every filter that can read a file or load a plugin is simply absent from
|
||||
* the table, so it cannot be named.
|
||||
*/
|
||||
function filterChain(table: Record<string, Record<string, FfmpegParam>>, raw: unknown, path: string): string {
|
||||
if (raw === undefined || raw === null) return "";
|
||||
if (!Array.isArray(raw)) throw invalid(`${path} must be an array of filters`, path);
|
||||
|
||||
return raw
|
||||
.map((entry, i) => {
|
||||
const at = `${path}[${i}]`;
|
||||
if (!entry || typeof entry !== "object") throw invalid(`${at} must be an object`, at);
|
||||
|
||||
const pairs = Object.entries(entry as Record<string, unknown>);
|
||||
if (pairs.length !== 1) throw invalid(`${at} must name exactly one filter`, at);
|
||||
|
||||
const [name, rawArgs] = pairs[0]!;
|
||||
const spec = table[name];
|
||||
if (!spec) throw invalid(`Unknown filter ${JSON.stringify(name)}. See docs/FFMPEG.md.`, `${at}.${name}`);
|
||||
|
||||
const args = Object.entries((rawArgs ?? {}) as Record<string, unknown>).map(([key, v]) => {
|
||||
const argSpec = spec[key];
|
||||
if (!argSpec) {
|
||||
throw invalid(`Unknown parameter ${JSON.stringify(key)} for ${name}`, `${at}.${name}.${key}`);
|
||||
}
|
||||
return `${key}=${value(argSpec, v, `${at}.${name}.${key}`)}`;
|
||||
});
|
||||
|
||||
return args.length ? `${name}=${args.join(":")}` : name;
|
||||
})
|
||||
.join(",");
|
||||
}
|
||||
|
||||
/** Run ffmpeg on an asset and store the result as a new asset. The input is never modified. */
|
||||
export async function runFfmpeg(body: FfmpegRequest): 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 an image. Use /v1/sharp for images.`, "input");
|
||||
}
|
||||
|
||||
// A preset supplies the options; anything given explicitly overrides it. So "web-1080 but crf 20"
|
||||
// does not mean writing out every flag again.
|
||||
let format = "mp4";
|
||||
let presetOptions: string[] = [];
|
||||
let presetFilters = "";
|
||||
|
||||
if (body.preset !== undefined) {
|
||||
if (typeof body.preset !== "string" || !PRESETS[body.preset]) {
|
||||
throw invalid(`preset must be one of: ${Object.keys(PRESETS).join(", ")}`, "preset");
|
||||
}
|
||||
const preset = PRESETS[body.preset]!;
|
||||
format = preset.format;
|
||||
presetOptions = options(OUTPUT_OPTIONS, preset.options, "preset");
|
||||
presetFilters = preset.filters?.v
|
||||
? filterChain(VIDEO_FILTERS, preset.filters.v.map((f) => ({ [f.name]: f.args })), "preset.filters")
|
||||
: "";
|
||||
}
|
||||
|
||||
if (body.format !== undefined) {
|
||||
if (typeof body.format !== "string" || !FORMATS[body.format]) {
|
||||
throw invalid(`format must be one of: ${Object.keys(FORMATS).join(", ")}`, "format");
|
||||
}
|
||||
format = body.format;
|
||||
}
|
||||
|
||||
const inputArgs = options(INPUT_OPTIONS, body.inputOptions, "inputOptions");
|
||||
const outputArgs = options(OUTPUT_OPTIONS, body.outputOptions, "outputOptions");
|
||||
|
||||
const filters = (body.filters ?? {}) as Record<string, unknown>;
|
||||
const videoChain = filterChain(VIDEO_FILTERS, filters.v, "filters.v") || presetFilters;
|
||||
const audioChain = filterChain(AUDIO_FILTERS, filters.a, "filters.a");
|
||||
|
||||
// One directory per job, removed however this ends — otherwise disk fills with the failures.
|
||||
const workDir = await mkdtemp(join(tmpdir(), "avclub-"));
|
||||
const output = join(workDir, `out.${FORMATS[format]!.extension}`);
|
||||
|
||||
try {
|
||||
const argv = [
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
"-y",
|
||||
"-v", "error",
|
||||
// Nothing but local files. Kills SSRF and the concat:/subfile: tricks outright — the second
|
||||
// lock behind "the caller never names a path".
|
||||
"-protocol_whitelist", "file",
|
||||
...inputArgs,
|
||||
"-i", contentPath(source.id),
|
||||
...(videoChain ? ["-vf", videoChain] : []),
|
||||
...(audioChain ? ["-af", audioChain] : []),
|
||||
...presetOptions,
|
||||
// After the preset, so an explicit option wins.
|
||||
...outputArgs,
|
||||
"-f", format,
|
||||
output,
|
||||
];
|
||||
|
||||
const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
|
||||
|
||||
// A crafted file can make an encoder run effectively forever.
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
proc.kill();
|
||||
}, JOB_TIMEOUT_MS);
|
||||
|
||||
const [stderr, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
|
||||
clearTimeout(timer);
|
||||
|
||||
if (timedOut) {
|
||||
throw new ApiError("timeout", `Encoding took longer than ${JOB_TIMEOUT_MS / 1000}s and was stopped.`);
|
||||
}
|
||||
if (code !== 0) {
|
||||
throw unprocessable(`ffmpeg failed: ${stderr.trim().split("\n").at(-1) ?? `exit ${code}`}`);
|
||||
}
|
||||
|
||||
return await putAsset(await Bun.file(output).arrayBuffer());
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* The ffmpeg surface av-club exposes, as data — the same shape as the sharp vocabulary.
|
||||
*
|
||||
* Options are spelled the way ffmpeg spells them (`c:v`, `crf`, `pix_fmt`), so ffmpeg's own
|
||||
* documentation is the reference. What is NOT here is anything that names a file: no `-i`, no output
|
||||
* path, no protocol. See docs/SECURITY.md — that single restriction is what makes the rest of this
|
||||
* safe to open up.
|
||||
*/
|
||||
|
||||
export type FfmpegParam =
|
||||
| { kind: "int"; min: number; max: number; note?: string }
|
||||
| { kind: "number"; min: number; max: number; note?: string }
|
||||
| { kind: "enum"; values: readonly string[]; note?: string }
|
||||
| { kind: "bitrate"; note?: string };
|
||||
|
||||
/** Options that go BEFORE `-i`: seeking and trimming, which are far cheaper applied to the input. */
|
||||
export const INPUT_OPTIONS: Record<string, FfmpegParam> = {
|
||||
ss: { kind: "number", min: 0, max: 86_400, note: "Start at this many seconds." },
|
||||
t: { kind: "number", min: 0.001, max: 86_400, note: "Take this many seconds." },
|
||||
to: { kind: "number", min: 0, max: 86_400, note: "Stop at this timestamp." },
|
||||
};
|
||||
|
||||
/**
|
||||
* Output options.
|
||||
*
|
||||
* The encoder list is short on purpose: these are the codecs a browser plays. An encoder ffmpeg
|
||||
* happens to have compiled in is not automatically something worth publishing.
|
||||
*/
|
||||
export const OUTPUT_OPTIONS: Record<string, FfmpegParam> = {
|
||||
"c:v": { kind: "enum", values: ["libx264", "libvpx-vp9", "libaom-av1", "copy"], note: "Video encoder. libx264 is the one every browser plays." },
|
||||
"c:a": { kind: "enum", values: ["aac", "libopus", "copy"], note: "Audio encoder." },
|
||||
crf: { kind: "int", min: 0, max: 63, note: "Quality. Lower is better and bigger; 23 is a sane default for x264." },
|
||||
preset: { kind: "enum", values: ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"], note: "Encoder effort. Slower is smaller at the same quality." },
|
||||
tune: { kind: "enum", values: ["film", "animation", "grain", "stillimage", "fastdecode", "zerolatency"] },
|
||||
"profile:v": { kind: "enum", values: ["baseline", "main", "high"], note: "`high` is fine everywhere current." },
|
||||
level: { kind: "enum", values: ["3.0", "3.1", "4.0", "4.1", "4.2", "5.0", "5.1"] },
|
||||
pix_fmt: { kind: "enum", values: ["yuv420p", "yuv422p", "yuv444p"], note: "yuv420p, or Safari and QuickTime will not play it." },
|
||||
"b:v": { kind: "bitrate", note: "Target video bitrate, e.g. 2M." },
|
||||
"b:a": { kind: "bitrate", note: "Target audio bitrate, e.g. 128k." },
|
||||
maxrate: { kind: "bitrate" },
|
||||
bufsize: { kind: "bitrate" },
|
||||
r: { kind: "number", min: 1, max: 120, note: "Output frame rate." },
|
||||
g: { kind: "int", min: 1, max: 600, note: "Keyframe interval." },
|
||||
ar: { kind: "enum", values: ["22050", "44100", "48000"], note: "Audio sample rate." },
|
||||
ac: { kind: "int", min: 1, max: 2 },
|
||||
"frames:v": { kind: "int", min: 1, max: 10_000, note: "Stop after this many frames — 1 gives a poster." },
|
||||
movflags: { kind: "enum", values: ["+faststart"], note: "Moves the index to the front so playback can start before the file has arrived. Always want this for web." },
|
||||
an: { kind: "enum", values: ["1"], note: "Drop audio entirely." },
|
||||
};
|
||||
|
||||
/** Output containers. `image2` is how a single frame comes out as a still. */
|
||||
export const FORMATS: Record<string, { extension: string; describe: string }> = {
|
||||
mp4: { extension: "mp4", describe: "H.264/AAC in MP4 — plays everywhere." },
|
||||
webm: { extension: "webm", describe: "VP9/Opus. Smaller, but not on older Safari." },
|
||||
mp3: { extension: "mp3", describe: "Audio only." },
|
||||
image2: { extension: "jpg", describe: "A single frame, for poster images." },
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters, default-deny.
|
||||
*
|
||||
* ffmpeg's filter graph is a language, and most of it is arithmetic on pixels. A small, specific part
|
||||
* of it reads files or loads native code — `movie`, `subtitles`, `frei0r`, `ladspa` and relatives —
|
||||
* and those are not in this list, so they are refused. A filter nobody has classified is refused too:
|
||||
* a blocklist would silently admit whatever the next ffmpeg release adds.
|
||||
*/
|
||||
export const VIDEO_FILTERS: Record<string, Record<string, FfmpegParam>> = {
|
||||
scale: {
|
||||
w: { kind: "int", min: -2, max: 8192, note: "-2 keeps the aspect ratio and an even number of pixels, which h264 requires." },
|
||||
h: { kind: "int", min: -2, max: 8192 },
|
||||
},
|
||||
crop: {
|
||||
w: { kind: "int", min: 1, max: 8192 },
|
||||
h: { kind: "int", min: 1, max: 8192 },
|
||||
x: { kind: "int", min: 0, max: 8192 },
|
||||
y: { kind: "int", min: 0, max: 8192 },
|
||||
},
|
||||
fps: { fps: { kind: "number", min: 1, max: 120 } },
|
||||
format: { pix_fmts: { kind: "enum", values: ["yuv420p", "yuv422p", "yuv444p", "rgb24"] } },
|
||||
transpose: { dir: { kind: "enum", values: ["0", "1", "2", "3"] } },
|
||||
hflip: {},
|
||||
vflip: {},
|
||||
eq: {
|
||||
brightness: { kind: "number", min: -1, max: 1 },
|
||||
contrast: { kind: "number", min: -1000, max: 1000 },
|
||||
saturation: { kind: "number", min: 0, max: 3 },
|
||||
gamma: { kind: "number", min: 0.1, max: 10 },
|
||||
},
|
||||
unsharp: {
|
||||
luma_msize_x: { kind: "int", min: 3, max: 23 },
|
||||
luma_msize_y: { kind: "int", min: 3, max: 23 },
|
||||
luma_amount: { kind: "number", min: -2, max: 5 },
|
||||
},
|
||||
};
|
||||
|
||||
export const AUDIO_FILTERS: Record<string, Record<string, FfmpegParam>> = {
|
||||
volume: { volume: { kind: "number", min: 0, max: 10 } },
|
||||
aresample: { osr: { kind: "enum", values: ["22050", "44100", "48000"] } },
|
||||
loudnorm: {
|
||||
I: { kind: "number", min: -70, max: -5, note: "Target loudness in LUFS. -16 suits the web." },
|
||||
TP: { kind: "number", min: -9, max: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Named presets, so a caller does not have to know encoder settings to get a web-playable file.
|
||||
*
|
||||
* This is where the media knowledge lives — the whole reason av-club is a separate repo. A consumer
|
||||
* says what the media is FOR; what that means in flags is decided here and can be revised in one
|
||||
* place for every site that uses it.
|
||||
*/
|
||||
export const PRESETS: Record<string, { describe: string; format: string; options: Record<string, string | number>; filters?: { v?: { name: string; args: Record<string, number | string> }[] } }> = {
|
||||
"web-1080": {
|
||||
describe: "H.264 up to 1080p. The safe default — plays in every browser.",
|
||||
format: "mp4",
|
||||
options: { "c:v": "libx264", crf: 23, preset: "medium", "profile:v": "high", pix_fmt: "yuv420p", "c:a": "aac", "b:a": "128k", movflags: "+faststart" },
|
||||
filters: { v: [{ name: "scale", args: { w: -2, h: 1080 } }] },
|
||||
},
|
||||
"web-720": {
|
||||
describe: "H.264 up to 720p.",
|
||||
format: "mp4",
|
||||
options: { "c:v": "libx264", crf: 23, preset: "medium", "profile:v": "high", pix_fmt: "yuv420p", "c:a": "aac", "b:a": "128k", movflags: "+faststart" },
|
||||
filters: { v: [{ name: "scale", args: { w: -2, h: 720 } }] },
|
||||
},
|
||||
"web-480": {
|
||||
describe: "H.264 up to 480p, for slow connections.",
|
||||
format: "mp4",
|
||||
options: { "c:v": "libx264", crf: 26, preset: "medium", "profile:v": "main", pix_fmt: "yuv420p", "c:a": "aac", "b:a": "96k", movflags: "+faststart" },
|
||||
filters: { v: [{ name: "scale", args: { w: -2, h: 480 } }] },
|
||||
},
|
||||
poster: {
|
||||
describe: "One frame as a JPEG, for a video's poster image.",
|
||||
format: "image2",
|
||||
options: { "frames:v": 1 },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { ASSETS_DIR, MAX_CONCURRENT_JOBS, MAX_UPLOAD_BYTES, PORT, requireToken } from "./config";
|
||||
import { ApiError, invalid, notFound, toErrorResponse } from "./errors";
|
||||
import { contentPath, deleteAsset, putAsset, readAsset } from "./assets/store";
|
||||
import { ffprobe } from "./probe/ffprobe";
|
||||
import { runSharp, type SharpRequest } from "./sharp/run";
|
||||
import { runFfmpeg, type FfmpegRequest } from "./ffmpeg/run";
|
||||
import sharp from "sharp";
|
||||
import { createSidecarConnector } from "@@/sidecar/connect";
|
||||
import { API_URL } from "@@/officer-url.mjs";
|
||||
|
||||
/**
|
||||
* av-club — ffmpeg, sharp and ffprobe over HTTP.
|
||||
*
|
||||
* The security model is in docs/SECURITY.md and is not optional reading: the caller never names a
|
||||
* path, and that single invariant is what makes it safe to expose the rest of these tools' surface.
|
||||
*/
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Read once, at boot, so a missing token stops the process here rather than at the first request.
|
||||
const TOKEN = requireToken();
|
||||
|
||||
/**
|
||||
* Constant-time bearer check.
|
||||
*
|
||||
* `===` on secrets leaks their length and their common prefix through timing. The cost of doing it
|
||||
* properly is three lines.
|
||||
*/
|
||||
function authorised(request: Request): boolean {
|
||||
/*
|
||||
The platform, having already authenticated somebody.
|
||||
|
||||
`createSidecarProxy` forwards `X-Officer-User` after the account gate and the `av-club` permission
|
||||
gate have both passed, and this server binds LOOPBACK — so that header is only settable by something
|
||||
already on this machine, which is the same trust boundary caldav's DAV door relies on. Requiring the
|
||||
bearer token as well would mean shipping av-club's secret to every caller for no gain: the platform
|
||||
is the thing deciding who may encode, and it has already decided.
|
||||
*/
|
||||
if (request.headers.get("x-officer-user")) return true;
|
||||
|
||||
const header = request.headers.get("authorization");
|
||||
if (!header?.startsWith("Bearer ")) return false;
|
||||
|
||||
const provided = encoder.encode(header.slice(7));
|
||||
const expected = encoder.encode(TOKEN);
|
||||
if (provided.byteLength !== expected.byteLength) return false;
|
||||
|
||||
return timingSafeEqual(provided, expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound concurrent processing.
|
||||
*
|
||||
* sharp and ffmpeg will each take every core available. Without this, two large requests can make
|
||||
* the box unresponsive to everything including the health check.
|
||||
*/
|
||||
let running = 0;
|
||||
const queue: (() => void)[] = [];
|
||||
|
||||
async function withSlot<T>(work: () => Promise<T>): Promise<T> {
|
||||
if (running >= MAX_CONCURRENT_JOBS) {
|
||||
await new Promise<void>((resolve) => queue.push(resolve));
|
||||
}
|
||||
running++;
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
running--;
|
||||
queue.shift()?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Serve stored bytes, honouring Range — browsers will not scrub a <video> served without it. */
|
||||
async function serveContent(id: string, request: Request): Promise<Response> {
|
||||
const asset = await readAsset(id);
|
||||
const file = Bun.file(contentPath(id));
|
||||
|
||||
const type =
|
||||
asset.kind === "image"
|
||||
? `image/${asset.format === "jpeg" ? "jpeg" : asset.format}`
|
||||
: file.type || "application/octet-stream";
|
||||
|
||||
const range = request.headers.get("range");
|
||||
const match = range?.match(/^bytes=(\d*)-(\d*)$/);
|
||||
|
||||
if (match) {
|
||||
const size = asset.bytes;
|
||||
const start = match[1] ? Number(match[1]) : 0;
|
||||
const end = match[2] ? Math.min(Number(match[2]), size - 1) : size - 1;
|
||||
|
||||
if (!Number.isFinite(start) || start > end || start >= size) {
|
||||
return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}` } });
|
||||
}
|
||||
|
||||
return new Response(file.slice(start, end + 1), {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Type": type,
|
||||
"Content-Range": `bytes ${start}-${end}/${size}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": String(end - start + 1),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": type,
|
||||
"Accept-Ranges": "bytes",
|
||||
// Every id is unique and content never changes under one, so this is honest.
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleUpload(request: Request): Promise<Response> {
|
||||
const form = await request.formData().catch(() => {
|
||||
throw invalid("Expected a multipart/form-data body with a `file` field");
|
||||
});
|
||||
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) throw invalid("Expected a `file` field containing a file", "file");
|
||||
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
throw new ApiError(
|
||||
"payload_too_large",
|
||||
`That file is ${(file.size / 1_048_576).toFixed(1)} MB. The limit is ${(MAX_UPLOAD_BYTES / 1_048_576).toFixed(0)} MB.`,
|
||||
);
|
||||
}
|
||||
|
||||
const asset = await withSlot(async () => putAsset(await file.arrayBuffer()));
|
||||
return Response.json(asset, { status: 201 });
|
||||
}
|
||||
|
||||
async function route(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === "/health") {
|
||||
return Response.json({ ok: true, service: "av-club", running, queued: queue.length });
|
||||
}
|
||||
|
||||
if (!authorised(request)) {
|
||||
return new ApiError("unauthorized", "Missing or invalid bearer token").toResponse();
|
||||
}
|
||||
|
||||
if (path === "/v1/assets" && request.method === "POST") return handleUpload(request);
|
||||
|
||||
const asset = path.match(/^\/v1\/assets\/([^/]+)(\/content|\/probe)?$/);
|
||||
if (asset) {
|
||||
const [, id, suffix] = asset;
|
||||
|
||||
if (suffix === "/content" && request.method === "GET") return serveContent(id!, request);
|
||||
|
||||
if (suffix === "/probe" && request.method === "GET") {
|
||||
const stored = await readAsset(id!);
|
||||
if (stored.kind === "image") {
|
||||
const metadata = await sharp(contentPath(id!)).metadata();
|
||||
return Response.json({ kind: "image", metadata });
|
||||
}
|
||||
const probe = await withSlot(() => ffprobe(contentPath(id!)));
|
||||
return Response.json({ kind: stored.kind, ...probe });
|
||||
}
|
||||
|
||||
if (!suffix && request.method === "GET") return Response.json(await readAsset(id!));
|
||||
|
||||
if (!suffix && request.method === "DELETE") {
|
||||
if (!(await readAsset(id!).then(() => true).catch(() => false))) throw notFound();
|
||||
await deleteAsset(id!);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
}
|
||||
|
||||
if (path === "/v1/sharp" && request.method === "POST") {
|
||||
const body = (await request.json().catch(() => {
|
||||
throw invalid("Expected a JSON body");
|
||||
})) as SharpRequest;
|
||||
const result = await withSlot(() => runSharp(body));
|
||||
return Response.json(result, { status: 201 });
|
||||
}
|
||||
|
||||
if (path === "/v1/ffmpeg" && request.method === "POST") {
|
||||
const body = (await request.json().catch(() => {
|
||||
throw invalid("Expected a JSON body");
|
||||
})) as FfmpegRequest;
|
||||
const result = await withSlot(() => runFfmpeg(body));
|
||||
return Response.json(result, { status: 201 });
|
||||
}
|
||||
|
||||
throw notFound(`No route for ${request.method} ${path}`);
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
port: PORT,
|
||||
// Loopback, because `X-Officer-User` above is trusted and a header is only as good as who can set it.
|
||||
hostname: "127.0.0.1",
|
||||
// Bun's default is 10 seconds, which silently kills a long encode mid-flight.
|
||||
idleTimeout: 255,
|
||||
maxRequestBodySize: MAX_UPLOAD_BYTES,
|
||||
fetch: (request) => route(request).catch(toErrorResponse),
|
||||
});
|
||||
|
||||
/*
|
||||
Register with the platform, and report the port it can be reached on.
|
||||
|
||||
The port is still `AV_CLUB_PORT` rather than an ephemeral one: this service is also usable directly —
|
||||
by a script on the box, or by fofos/platform, which is its stated first consumer and is not Officer.
|
||||
Being a plugin is an additional way in, not a replacement for the one it already had.
|
||||
*/
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: "av-club",
|
||||
handles: ["av-club"],
|
||||
onCommand(_cmd, reply) {
|
||||
reply({ type: "error", id: (_cmd as { id: string }).id, error: "av-club takes no commands; use its HTTP API" });
|
||||
},
|
||||
onConnected() {
|
||||
connection.send({ type: "av-club:server", port: PORT });
|
||||
console.log(`[av-club] reported port ${PORT} to the API`);
|
||||
},
|
||||
});
|
||||
|
||||
function shutdown(signal: string) {
|
||||
console.log(`[av-club] ${signal} received, shutting down...`);
|
||||
try {
|
||||
server.stop(true);
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
connection.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
|
||||
console.log(`av-club on ${server.url}`);
|
||||
console.log(` assets ${ASSETS_DIR}`);
|
||||
console.log(` max upload ${(MAX_UPLOAD_BYTES / 1_048_576).toFixed(0)} MB`);
|
||||
console.log(` concurrency ${MAX_CONCURRENT_JOBS}`);
|
||||
@@ -0,0 +1,62 @@
|
||||
import { JOB_TIMEOUT_MS } from "../config";
|
||||
import { unprocessable } from "../errors";
|
||||
|
||||
/**
|
||||
* ffprobe, with its own field names passed through untouched.
|
||||
*
|
||||
* `codec_name`, `r_frame_rate`, `nb_frames` and the rest reach the caller exactly as ffprobe emits
|
||||
* them. Renaming them into some house style would mean maintaining a translation table and would
|
||||
* make ffprobe's own documentation stop applying to this API, which is the opposite of the point.
|
||||
*/
|
||||
|
||||
export type FfprobeStream = {
|
||||
index: number;
|
||||
codec_type?: string;
|
||||
codec_name?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type FfprobeResult = {
|
||||
format?: { format_name?: string; duration?: string; bit_rate?: string; [key: string]: unknown };
|
||||
streams?: FfprobeStream[];
|
||||
};
|
||||
|
||||
export async function ffprobe(path: string): Promise<FfprobeResult> {
|
||||
// Spawned as an argv ARRAY, never a command string — there is no shell here to quote for, so a
|
||||
// filename containing shell metacharacters is inert data. See docs/SECURITY.md, Layer 1.
|
||||
//
|
||||
// `-protocol_whitelist file` is the second lock: `path` is always one this process constructed,
|
||||
// but if that ever stops being true, ffprobe still cannot be talked into speaking http.
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v", "error",
|
||||
"-protocol_whitelist", "file",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
path,
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe", stdin: "ignore" },
|
||||
);
|
||||
|
||||
const timer = setTimeout(() => proc.kill(), JOB_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
if (code !== 0) {
|
||||
throw unprocessable(`Could not read that file: ${stderr.trim().split("\n").at(-1) ?? "ffprobe failed"}`);
|
||||
}
|
||||
|
||||
return JSON.parse(stdout) as FfprobeResult;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -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