Files
av-club/sidecar/sharp/run.ts
T
pastilhasandClaude Opus 5 c69f6d12a3 Convert Image, and HEIC opens because av-club already knew how
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>
2026-08-29 02:28:06 +01:00

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 };
}