Files
av-club/sidecar/sharp/decodable.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

86 lines
3.5 KiB
TypeScript

import { join } from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { JOB_TIMEOUT_MS } from "../config";
import { unprocessable } from "../errors";
/**
* Getting HEIC into something sharp can actually decode.
*
* ── Why this exists ──
*
* sharp ships its own libvips and libheif, built without the libde265 plugin, so it can parse a
* HEIC's header — dimensions come back fine — and then fails on the pixels with "bad seek", which
* reads like a corrupt file and is not one. Every photograph an iPhone takes is HEVC-coded HEIF, so
* this is not an edge case: it is most of what a client will upload from a phone.
*
* ImageMagick links the SYSTEM libheif, which does have the plugin (see docs/TOOLING.md), so it
* decodes them. This converts through it and hands sharp a PNG.
*
* PNG rather than JPEG for the intermediate: it is lossless, so the one real encode is the final one
* and the derivative is not a copy of a copy.
*/
/** Formats sharp reports but cannot decode here. Extend only with evidence, not suspicion. */
const NEEDS_CONVERSION = new Set(["heif", "heic", "avif-sequence"]);
/**
* Does this format have to go through ImageMagick before sharp can read its pixels?
*
* Exported because it is expensive enough to plan around, not merely to handle. A caller building
* several derivatives of one file pays this conversion PER REQUEST — measured at 8.5 s against 1.1 s
* for the resize and encode that follow it — so a seven-rung ladder spends most of its time producing
* the same intermediate seven times. Knowing in advance lets that caller decode once itself.
*
* It is a function here rather than a copy of the set over there on purpose: which formats need this
* changes when the build changes, and a consumer holding its own list would keep paying for a
* conversion that had stopped being necessary.
*/
export const needsConversion = (format: string): boolean => NEEDS_CONVERSION.has(format.toLowerCase());
export type Decodable = {
path: string;
/** Call when finished. Removes the intermediate, if there was one. */
cleanup: () => Promise<void>;
};
export async function decodableInput(path: string, format: string): Promise<Decodable> {
if (!NEEDS_CONVERSION.has(format.toLowerCase())) {
return { path, cleanup: async () => {} };
}
const workDir = await mkdtemp(join(tmpdir(), "avclub-heif-"));
const converted = join(workDir, "decoded.png");
// ImageMagick 6 on Ubuntu 24.04, so `convert` — there is no `magick`. Spawned as an argv array,
// so the path is data rather than something a shell parses.
//
// -auto-orient applies the EXIF rotation while the metadata still exists; the PNG carries none, so
// this is the last chance to get a phone photograph the right way up.
const proc = Bun.spawn(["convert", path, "-auto-orient", converted], {
stdout: "pipe",
stderr: "pipe",
stdin: "ignore",
});
const timer = setTimeout(() => proc.kill(), JOB_TIMEOUT_MS);
try {
const [stderr, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
if (code !== 0 || !(await Bun.file(converted).exists())) {
await rm(workDir, { recursive: true, force: true });
throw unprocessable(
`Could not read that HEIC: ${stderr.trim().split("\n").at(-1) ?? "conversion failed"}`,
);
}
return {
path: converted,
cleanup: () => rm(workDir, { recursive: true, force: true }),
};
} finally {
clearTimeout(timer);
}
}