Files
av-club/sidecar/sharp/decodable.ts
T
pastilhasandClaude Opus 5 79ff366802 become an Officer plugin
av-club keeps doing exactly what it did — sharp, ffmpeg and ffprobe behind one
API, and the invariant that the caller never names a path. What changes is how it
is reached and who decides who may reach it.

`src/` is `sidecar/`, because that is the name the platform looks for:
`sidecar/index.ts` present means "this plugin ships a process", and presence is
the declaration for everything structural here. `manifest.ts` at the root is the
other half of that rule.

api/router.ts is a proxy and nothing else. the platform authenticates, checks the
`av-club` permission, and forwards — so the sidecar now accepts a request
carrying `X-Officer-User` and no longer demands its own bearer token from
callers who came that way. it binds LOOPBACK to make that trustworthy, which is
the same boundary caldav's DAV door already relies on. AV_CLUB_TOKEN stays, and
still guards anything calling the port directly: fofos/platform is the stated
first consumer and is not Officer.

THREE THINGS HAD TO GO, and each one broke it in a way worth writing down.

package.json and bun.lock: a package.json inside a plugin is a package boundary,
so bun stops walking up for the platform's tsconfig and `@@/sidecar/connect`
stops resolving. the sidecar died on its first import, fifteen times. npm
dependencies go in the manifest instead, where npm-deps.ts intersects them across
plugins into one plugins/node_modules — which is also how this surfaced a real
conflict: sharp ^0.33.5 (atelier) and ^0.35.3 here do not overlap, since a 0.x
caret pins the minor. atelier moved up; it calls one method.

tsconfig.json: same failure one layer earlier, shadowing the platform's `@@/*`
paths so api/router.ts would not mount. no other plugin has one.

no screens, deliberately. this is an API and a body of documentation; its callers
are a commander watching a folder, an agent, or curl. a dock tile would advertise
a page with nothing on it.

verified through /api/av-club with an agent key: a 3000x2000 jpeg uploaded,
probed, and resized to a 1200x800 avif — 166 KB to 1 KB — without the caller ever
holding av-club's own token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 22:36:53 +01:00

72 lines
2.6 KiB
TypeScript

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