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"]); 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. 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); } }