Files
av-club/sidecar/sharp/run.ts
T
pastilhasandClaude Opus 5 07f050cb97 output takes a format NAME, and says so instead of ignoring you
`output: { webp: { quality: 82 } }` was accepted and did nothing. It reads
perfectly and mirrors how `pipeline` names an operation, so it is what a caller
writes from memory — and quietly it meant format fell through to the "webp"
default (right answer, wrong reason) while the options were never read, so the
quality asked for was not the quality encoded. Ask for `{ png: … }` the same way
and you got a WebP with a .png on the end of it.

Every other part of this API rejects a name it does not know. This one did not,
and what it let through was the kind of failure nobody finds by looking at the
output.

Also report `needsConversion` from `/v1/image`. Which formats have to go through
ImageMagick before sharp can read their pixels is this service's fact, and it is
expensive enough to plan around rather than merely absorb: 8.5 s against 1.1 s
for the resize and encode after it. A caller building a seven-rung ladder needs
to know so it can decode once itself, and a copy of the list kept on its side
would go on paying after this build stopped needing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:24:07 +01:00

203 lines
9.6 KiB
TypeScript

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";
import { safeInputPath, safeOutputPath } from "../paths";
import { stat } from "node:fs/promises";
export type SharpRequest = {
input?: unknown;
/** A path on disk, instead of `input`. Confined by `safeInputPath`. */
inputPath?: unknown;
/** Where to write. Only with `inputPath`; without it the result is stored as an asset. */
outputPath?: 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 type SharpResult = Asset | { path: string; bytes: number };
/**
* Run a validated sharp pipeline, on a stored asset or on a file already on disk.
*
* Two modes, exactly as `/v1/ffmpeg` has, and for the same reason: the file browser and the fofos
* commander both already have the file. See `sidecar/paths.ts`.
*
* The input is never modified in either mode — every operation produces a new file. That is what makes
* it safe to re-run a pipeline with different settings six months from now, which is why originals are
* kept at all (docs/PIPELINE.md: "The original is never destroyed").
*/
export async function runSharp(body: SharpRequest): Promise<SharpResult> {
const byPath = body.inputPath !== undefined;
if (byPath && body.input !== undefined) throw invalid("give either input or inputPath, not both", "input");
if (!byPath && typeof body.input !== "string") throw invalid("input must be an asset id", "input");
const sourcePath = byPath ? await safeInputPath(body.inputPath, "inputPath") : null;
const destination = body.outputPath !== undefined ? await safeOutputPath(body.outputPath, "outputPath") : null;
if (destination && !byPath) throw invalid("outputPath needs inputPath", "outputPath");
const source = byPath ? null : await readAsset(body.input as string);
if (source && 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.
/*
For a path we have no stored `format`, so sharp is asked. `metadata()` only reads the header, which
is precisely the part of a HEIC that works — the pixels are what its libheif cannot reach — so this
correctly identifies the files that need routing through ImageMagick.
*/
const sourceFile = sourcePath ?? contentPath(source!.id);
const sourceFormat = source?.format ?? (await sharp(sourceFile).metadata().catch(() => null))?.format ?? "";
const decoded = await decodableInput(sourceFile, sourceFormat);
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");
}
/*
Unknown keys are refused, because the shape of this object is guessable and the wrong guess is
SILENT.
`output: { webp: { quality: 82 } }` reads perfectly and mirrors how `pipeline` names an operation,
so it is what a caller writes from memory. Accepted quietly it meant: format fell through to the
"webp" default — right answer, wrong reason — and the options were never read, so the quality asked
for was not the quality encoded. Ask for `{ png: … }` the same way and you get a WebP with a .png
on the end of it.
Every other part of this API rejects a name it does not know. This one did not, and the failure it
let through was the kind nobody finds by looking at the output.
*/
const known = new Set(["format", "options"]);
const stray = Object.keys(output).filter((key) => !known.has(key));
if (stray.length) {
throw invalid(
`output has no ${stray.map((key) => JSON.stringify(key)).join(", ")}. It takes { "format": "webp", "options": { … } } — the format is a NAME, not a key.`,
"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();
if (!destination) return putAsset(buffer);
// Written whole, in one call, after the encode has already succeeded — so a failure leaves the
// destination untouched rather than half a file where a good one might have been.
await Bun.write(destination, buffer);
return { path: destination, bytes: (await stat(destination)).size };
}