ImageMagick sizes its pixel cache against the machine and will hold an entire decoded image in RAM. On a host with room that is right. On a small one it is how resizing a photograph kills whatever else is running — and "whatever else" includes the website this service exists to serve, which is not an acceptable way for an image to fail. So the decode runs under `-limit memory` / `-limit map` / `-limit disk`. Past the caps ImageMagick spills to a temporary file and carries on, which turns an out-of-memory kill of whatever the kernel picks into a conversion that is slower. Nobody minds a slow photograph; everybody minds a site that went down while one was being resized. Measured on fofos. A 12 MP HEIC peaks at 145 MB, so the 256 MB default does not bind and the output is byte-identical — it is a ceiling, not a tax. Forced to bind, on a 36 MP image with a 32 MiB cap, peak RSS is 12 MB and it still exits 0 with the file written. `-limit thread 1` for the same reason: one decode is one image, and each thread carries its own share of the cache the caps exist to bound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
115 lines
5.1 KiB
TypeScript
115 lines
5.1 KiB
TypeScript
import { join } from "node:path";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { CONVERT_MEMORY_MB, 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.
|
|
|
|
── THE LIMITS ARE THE POINT, AND THEY GO FIRST ──
|
|
|
|
Left alone, ImageMagick sizes its pixel cache against the machine and will happily hold an entire
|
|
decoded image in RAM. On a box with room that is exactly right. On a small one it is how a
|
|
photograph takes down everything else on the host — and "everything else" here includes the website
|
|
this service exists to serve, which is not an acceptable way for an image to fail.
|
|
|
|
`-limit memory` caps the in-RAM cache; `-limit map` caps the memory-mapped extension of it. Past
|
|
both, ImageMagick spills to a temporary FILE and carries on. So the failure mode changes from an
|
|
out-of-memory kill of whatever the kernel picks to a conversion that is slower than it would have
|
|
been. That is a trade worth making unconditionally: nobody minds a slow photograph, and everybody
|
|
minds a website that went down while one was being resized.
|
|
|
|
These are DEFAULTS, not a policy — `AV_CLUB_CONVERT_MEMORY_MB` raises them on a machine with the
|
|
headroom to be quicker.
|
|
*/
|
|
const limits = [
|
|
"-limit", "memory", `${CONVERT_MEMORY_MB}MiB`,
|
|
"-limit", "map", `${CONVERT_MEMORY_MB * 2}MiB`,
|
|
// The spill has to land somewhere, and unbounded is a full disk instead of a full memory. Ten
|
|
// times the memory cap is far more than any real photograph needs and still a number.
|
|
"-limit", "disk", `${CONVERT_MEMORY_MB * 10}MiB`,
|
|
// One decode is one image. Threads here buy little and each carries its own share of the cache,
|
|
// which is the opposite of what the caps above are for.
|
|
"-limit", "thread", "1",
|
|
];
|
|
|
|
const proc = Bun.spawn(["convert", ...limits, 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);
|
|
}
|
|
}
|