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>
This commit is contained in:
2026-08-29 02:28:06 +01:00
co-authored by Claude Opus 5
parent 7d57deeb3f
commit c69f6d12a3
4 changed files with 278 additions and 8 deletions
+1
View File
@@ -193,6 +193,7 @@ async function route(request: Request): Promise<Response> {
throw invalid("Expected a JSON body");
})) as SharpRequest;
const result = await withSlot(() => runSharp(body));
if (!("id" in result)) return Response.json(result, { status: 200 });
return Response.json(result, { status: 201 });
}
+42 -8
View File
@@ -5,9 +5,15 @@ 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;
@@ -20,13 +26,29 @@ export type SharpRequest = {
* 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");
}
export type SharpResult = Asset | { path: string; bytes: number };
const source = await readAsset(body.input);
if (source.kind !== "image") {
/**
* 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");
}
@@ -36,7 +58,14 @@ export async function runSharp(body: SharpRequest): Promise<Asset> {
// 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);
/*
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,
@@ -142,5 +171,10 @@ export async function runSharp(body: SharpRequest): Promise<Asset> {
}
await decoded.cleanup();
return putAsset(buffer);
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 };
}