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; }; export async function decodableInput(path: string, format: string): Promise { 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); } }