the fourth task. same shape as the other three — beside the original, skip an existing output, skip a file already in the target format, delete only when asked and only after the write. /v1/sharp gets the same path mode /v1/ffmpeg got, for the same reason: the file browser and the commander both already have the file. HEIC IS THE POINT OF THIS ONE, and nothing in the task does anything about it. `decodable.ts` has routed HEIC through ImageMagick since before I touched this plugin, because the libheif inside sharp is built without the HEVC decoder — a HEIC reads its own header fine and then fails on the pixels, which looks exactly like a corrupt file and is not one. the task simply names `.heic` in its trigger and asks. the photographs open. I had diagnosed that gap independently against the file browser's thumbnails and concluded a system decoder would be needed, twice telling the owner these files could not be read. this plugin had already solved it and documented why. worth the note: the answer was in the tree. a path has no stored `format`, so sharp's `metadata()` supplies it — and that only reads the header, which is precisely the part of a HEIC that works, so it correctly identifies the files needing ImageMagick. `rotate` with no argument is in every pipeline: it applies the EXIF orientation and drops the tag. without it a portrait photo from a phone converts to a landscape image that viewers rotate back, until one does not. max_dimension shrinks to fit and never enlarges. quality 82 for jpeg and webp, 55 for avif — where a photograph stops looking better and only gets bigger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
181 lines
8.5 KiB
TypeScript
181 lines
8.5 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");
|
|
}
|
|
|
|
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 };
|
|
}
|