From 100eee5256cd5d797b647cc58422ae9cd5260362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 11:13:43 +0100 Subject: [PATCH] an image's header, by path, because ffprobe cannot read a HEIC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/v1/probe` is ffprobe, which is the wrong tool for a still and fails at it loudly: a HEIC out of an iPhone is not a container it understands, so it rejects the file outright with "moov atom not found" rather than reporting no dimensions. So anything needing a photograph's size had nowhere to ask — and a caller building a width ladder needs exactly that, to know which rungs would be an upscale. sharp reads it, HEIC included, because `metadata()` only parses the header. That is precisely the part of a HEIC that works; the pixels are what its bundled libheif cannot reach, which is what decodable.ts routes around. No decode happens, so this needs no job slot. Co-Authored-By: Claude Opus 5 --- sidecar/index.ts | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/sidecar/index.ts b/sidecar/index.ts index 475760b..cada431 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -1,6 +1,6 @@ import { timingSafeEqual } from "node:crypto"; import { ASSETS_DIR, MAX_CONCURRENT_JOBS, MAX_UPLOAD_BYTES, PORT, requireToken } from "./config"; -import { ApiError, invalid, notFound, toErrorResponse } from "./errors"; +import { ApiError, invalid, notFound, toErrorResponse, unprocessable } from "./errors"; import { contentPath, deleteAsset, putAsset, readAsset } from "./assets/store"; import { ffprobe } from "./probe/ffprobe"; import { runSharp, type SharpRequest } from "./sharp/run"; @@ -225,6 +225,38 @@ async function route(request: Request): Promise { return Response.json(await withSlot(() => ffprobe(target))); } + /* + An image's header, by path. + + Separate from `/v1/probe` because ffprobe is the wrong tool for a still and fails at it loudly: a + HEIC out of an iPhone is not a media container it understands, and it rejects the file outright + ("moov atom not found") rather than reporting no dimensions. Anything that needs a photograph's + size — a caller building a width ladder, and therefore needing to know which rungs would be an + upscale — had nowhere to ask. + + sharp reads it, including HEIC, because `metadata()` only parses the header. That is precisely the + part of a HEIC that works; the pixels are what its bundled libheif cannot reach, which is what + `decodable.ts` exists to route around. No decode happens here, so this is cheap and needs no job + slot: it is a file header read, not an image operation. + */ + if (path === "/v1/image" && request.method === "GET") { + const target = await safeInputPath(url.searchParams.get("path"), "path"); + const meta = await sharp(target) + .metadata() + .catch(() => { + throw unprocessable(`Could not read that image: ${target}`); + }); + return Response.json({ + format: meta.format ?? null, + width: meta.width ?? null, + height: meta.height ?? null, + space: meta.space ?? null, + hasAlpha: meta.hasAlpha ?? false, + pages: meta.pages ?? 1, + orientation: meta.orientation ?? null, + }); + } + const asset = path.match(/^\/v1\/assets\/([^/]+)(\/content|\/probe)?$/); if (asset) { const [, id, suffix] = asset;