Files
pastilhasandClaude Opus 5 ec6e09014f a photograph may be slow, but it may not take the machine down
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>
2026-08-29 11:32:25 +01:00

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