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:
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user