Convert Image, and HEIC opens because av-club already knew how

the fourth task. same shape as the other three — beside the original, skip an
existing output, skip a file already in the target format, delete only when asked
and only after the write.

/v1/sharp gets the same path mode /v1/ffmpeg got, for the same reason: the file
browser and the commander both already have the file.

HEIC IS THE POINT OF THIS ONE, and nothing in the task does anything about it.
`decodable.ts` has routed HEIC through ImageMagick since before I touched this
plugin, because the libheif inside sharp is built without the HEVC decoder — a
HEIC reads its own header fine and then fails on the pixels, which looks exactly
like a corrupt file and is not one. the task simply names `.heic` in its trigger
and asks. the photographs open.

I had diagnosed that gap independently against the file browser's thumbnails and
concluded a system decoder would be needed, twice telling the owner these files
could not be read. this plugin had already solved it and documented why. worth the
note: the answer was in the tree.

a path has no stored `format`, so sharp's `metadata()` supplies it — and that only
reads the header, which is precisely the part of a HEIC that works, so it correctly
identifies the files needing ImageMagick.

`rotate` with no argument is in every pipeline: it applies the EXIF orientation and
drops the tag. without it a portrait photo from a phone converts to a landscape
image that viewers rotate back, until one does not.

max_dimension shrinks to fit and never enlarges. quality 82 for jpeg and webp, 55
for avif — where a photograph stops looking better and only gets bigger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 02:28:06 +01:00
co-authored by Claude Opus 5
parent 7d57deeb3f
commit c69f6d12a3
4 changed files with 278 additions and 8 deletions
+1
View File
@@ -193,6 +193,7 @@ async function route(request: Request): Promise<Response> {
throw invalid("Expected a JSON body"); throw invalid("Expected a JSON body");
})) as SharpRequest; })) as SharpRequest;
const result = await withSlot(() => runSharp(body)); const result = await withSlot(() => runSharp(body));
if (!("id" in result)) return Response.json(result, { status: 200 });
return Response.json(result, { status: 201 }); return Response.json(result, { status: 201 });
} }
+42 -8
View File
@@ -5,9 +5,15 @@ import { contentPath, putAsset, readAsset, type Asset } from "../assets/store";
import { INPUT_OPTIONS, OPERATIONS, OUTPUT_FORMATS } from "./vocabulary"; import { INPUT_OPTIONS, OPERATIONS, OUTPUT_FORMATS } from "./vocabulary";
import { validateObject } from "./validate"; import { validateObject } from "./validate";
import { decodableInput } from "./decodable"; import { decodableInput } from "./decodable";
import { safeInputPath, safeOutputPath } from "../paths";
import { stat } from "node:fs/promises";
export type SharpRequest = { export type SharpRequest = {
input?: unknown; input?: unknown;
/** A path on disk, instead of `input`. Confined by `safeInputPath`. */
inputPath?: unknown;
/** Where to write. Only with `inputPath`; without it the result is stored as an asset. */
outputPath?: unknown;
options?: unknown; options?: unknown;
pipeline?: unknown; pipeline?: unknown;
output?: unknown; output?: unknown;
@@ -20,13 +26,29 @@ export type SharpRequest = {
* safe to re-run a pipeline with different settings six months from now, which is the whole reason * safe to re-run a pipeline with different settings six months from now, which is the whole reason
* originals are kept (docs/PIPELINE.md: "The original is never destroyed"). * originals are kept (docs/PIPELINE.md: "The original is never destroyed").
*/ */
export async function runSharp(body: SharpRequest): Promise<Asset> { export type SharpResult = Asset | { path: string; bytes: number };
if (typeof body.input !== "string") {
throw invalid("input must be an asset id", "input");
}
const source = await readAsset(body.input); /**
if (source.kind !== "image") { * Run a validated sharp pipeline, on a stored asset or on a file already on disk.
*
* Two modes, exactly as `/v1/ffmpeg` has, and for the same reason: the file browser and the fofos
* commander both already have the file. See `sidecar/paths.ts`.
*
* The input is never modified in either mode — every operation produces a new file. That is what makes
* it safe to re-run a pipeline with different settings six months from now, which is why originals are
* kept at all (docs/PIPELINE.md: "The original is never destroyed").
*/
export async function runSharp(body: SharpRequest): Promise<SharpResult> {
const byPath = body.inputPath !== undefined;
if (byPath && body.input !== undefined) throw invalid("give either input or inputPath, not both", "input");
if (!byPath && typeof body.input !== "string") throw invalid("input must be an asset id", "input");
const sourcePath = byPath ? await safeInputPath(body.inputPath, "inputPath") : null;
const destination = body.outputPath !== undefined ? await safeOutputPath(body.outputPath, "outputPath") : null;
if (destination && !byPath) throw invalid("outputPath needs inputPath", "outputPath");
const source = byPath ? null : await readAsset(body.input as string);
if (source && source.kind !== "image") {
throw invalid(`Asset ${source.id} is ${source.kind}, not an image. Use /v1/ffmpeg for video and audio.`, "input"); throw invalid(`Asset ${source.id} is ${source.kind}, not an image. Use /v1/ffmpeg for video and audio.`, "input");
} }
@@ -36,7 +58,14 @@ export async function runSharp(body: SharpRequest): Promise<Asset> {
// guard, and a caller who could raise it could turn a 1 KB PNG into a 40 GB allocation. // guard, and a caller who could raise it could turn a 1 KB PNG into a 40 GB allocation.
// HEIC has to go through ImageMagick first — sharp's bundled libheif cannot decode it. A no-op // HEIC has to go through ImageMagick first — sharp's bundled libheif cannot decode it. A no-op
// for every other format. See decodable.ts. // for every other format. See decodable.ts.
const decoded = await decodableInput(contentPath(source.id), source.format); /*
For a path we have no stored `format`, so sharp is asked. `metadata()` only reads the header, which
is precisely the part of a HEIC that works — the pixels are what its libheif cannot reach — so this
correctly identifies the files that need routing through ImageMagick.
*/
const sourceFile = sourcePath ?? contentPath(source!.id);
const sourceFormat = source?.format ?? (await sharp(sourceFile).metadata().catch(() => null))?.format ?? "";
const decoded = await decodableInput(sourceFile, sourceFormat);
let image = sharp(decoded.path, { let image = sharp(decoded.path, {
...inputOptions, ...inputOptions,
@@ -142,5 +171,10 @@ export async function runSharp(body: SharpRequest): Promise<Asset> {
} }
await decoded.cleanup(); await decoded.cleanup();
return putAsset(buffer); if (!destination) return putAsset(buffer);
// Written whole, in one call, after the encode has already succeeded — so a failure leaves the
// destination untouched rather than half a file where a good one might have been.
await Bun.write(destination, buffer);
return { path: destination, bytes: (await stat(destination)).size };
} }
+52
View File
@@ -0,0 +1,52 @@
---
name: Convert Image
description: Convert photos between formats through av-club — including HEIC from a phone, which most tools cannot read. Optionally shrink to fit a maximum size.
category: Image
version: 1
mode: script
language: typescript
inline: ask
args:
- file_path
inputs:
file_path:
type: string
description: An image, or a folder to convert recursively.
target_format:
type: string
default: jpeg
options: [jpeg, png, webp, avif, tiff]
description: Format to convert to.
max_dimension:
type: string
default: original
options: [original, "3000", "2000", "1600", "1200", "800"]
description: Shrink so neither side exceeds this, keeping the aspect ratio. Never enlarges.
delete_source:
type: boolean
default: false
description: Delete each source file once its conversion has been written.
trigger:
- type: file
extensions: [heic, heif, jpg, jpeg, png, webp, avif, tif, tiff, gif, bmp, jp2]
- type: directory
---
# Convert Image
Writes the result **beside the original** — `IMG_3707.HEIC` becomes `IMG_3707.jpeg` in the same folder.
**HEIC works here**, which is worth saying because it does not work in most places. Every photo a
recent iPhone takes is HEVC-coded HEIF, and the libheif that ships inside sharp is built without the
decoder for it — so a HEIC reads its own header fine and then fails on the pixels, looking exactly
like a corrupt file. av-club routes those through ImageMagick, which links the system libheif and can
decode them, and hands sharp a lossless PNG. You do not have to know any of that; it just opens.
`max_dimension` shrinks so neither side is larger than the number, keeping the aspect ratio, and never
enlarges a small image. Leave it on `original` to convert without resizing.
Skips a file already in the target format, and never overwrites an existing output.
Quality is 82 for jpeg and webp and 55 for avif — the point where a photograph stops getting visibly
better and only gets bigger. EXIF orientation is applied, so a photo taken sideways comes out upright
rather than rotated.
+183
View File
@@ -0,0 +1,183 @@
/**
* Convert Image — a thin client of av-club's `/v1/sharp`.
*
* ── Why this file repeats itself against its two siblings ──
*
* The task runner materialises ONLY `index.ts` into a temp directory, so a script task cannot import
* a sibling. Extract Audio and Convert Video therefore carry their own copies of the walk, the
* skip rules and the reporting. That duplication is the platform's shape, not a choice — and the part
* that actually matters, what ffmpeg is told, lives once in av-club rather than three times here.
*
* ── The rules are the conversion tasks' rules, and they are the same three ──
*
* Skip a file already in the target format; skip when the output exists; never overwrite.
*
* ── HEIC is the reason this task is worth having ──
*
* Nothing here does anything about it, which is the point: av-club already routes HEIC through
* ImageMagick because the libheif inside sharp cannot decode HEVC. This task just names `.heic` in its
* trigger and asks, and the photographs open.
*/
const API = process.env.OFFICER_API_URL;
const HOST = process.env.OFFICER_API_HOST;
const TOKEN = process.env.OFFICER_AUTH_TOKEN;
if (!API || !TOKEN) {
console.error('No OFFICER_API_URL / OFFICER_AUTH_TOKEN — this only runs as a task.');
process.exit(1);
}
const target = (process.env.INPUT_TARGET_FORMAT || 'jpeg').toLowerCase();
const deleteSource = (process.env.INPUT_DELETE_SOURCE || 'false') === 'true';
const start = process.argv[2] || process.env.INPUT_FILE_PATH;
if (!start) {
console.error('No file or folder given.');
process.exit(1);
}
const SOURCE_EXT = new Set([
'heic', 'heif', 'jpg', 'jpeg', 'png', 'webp', 'avif', 'tif', 'tiff', 'gif', 'bmp', 'jp2',
]);
/** `.jpg` and `.jpeg` are the same format; sharp calls it `jpeg` and so does the output extension. */
const CANONICAL: Record<string, string> = { jpg: 'jpeg', tif: 'tiff', heif: 'heic' };
/**
* Encoder settings per format.
*
* 82 for jpeg and webp, 55 for avif — the point where a photograph stops looking visibly better and
* only gets bigger. png is lossless, so the number there is effort rather than quality.
*/
const ENCODING: Record<string, Record<string, unknown>> = {
jpeg: { quality: 82, mozjpeg: true },
webp: { quality: 82 },
avif: { quality: 55 },
png: { compressionLevel: 9 },
tiff: { compression: 'lzw' },
};
if (!ENCODING[target]) {
console.error(`Cannot convert to ${target}. Choose one of: ${Object.keys(ENCODING).join(', ')}`);
process.exit(1);
}
const maxDimension = (() => {
const raw = (process.env.INPUT_MAX_DIMENSION || 'original').trim();
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : null;
})();
const { readdir, stat, unlink } = await import('node:fs/promises');
const { join, extname, dirname, basename } = await import('node:path');
const headers: Record<string, string> = {
Authorization: `Bearer ${TOKEN}`,
'content-type': 'application/json',
...(HOST ? { Host: HOST } : {}),
};
const exists = async (p: string) => !!(await stat(p).catch(() => null));
/** Every audio file under a folder, deepest-last, sorted so a run is reproducible. */
async function collect(dir: string): Promise<string[]> {
const found: string[] = [];
for (const entry of (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
const full = join(dir, entry.name);
if (entry.isDirectory()) found.push(...(await collect(full)));
else if (SOURCE_EXT.has(extname(entry.name).slice(1).toLowerCase())) found.push(full);
}
return found;
}
const info = await stat(start).catch(() => null);
if (!info) {
console.error(`Not there: ${start}`);
process.exit(1);
}
const files = info.isDirectory() ? await collect(start) : [start];
if (files.length === 0) {
console.log(`No images in ${start}`);
process.exit(0);
}
let done = 0;
let skipped = 0;
let failed = 0;
for (const [index, input] of files.entries()) {
const name = basename(input);
const raw = extname(input).slice(1).toLowerCase();
const ext = CANONICAL[raw] ?? raw;
const output = join(dirname(input), `${basename(input, extname(input))}.${target}`);
// The sentinel the jobs view reads for its progress bar. Printed before the work, so a long
// conversion shows which file it is on rather than jumping when it finishes.
console.log(`@@officer:progress@@ ${JSON.stringify({ current: index + 1, total: files.length, label: name })}`);
if (ext === target) {
console.log(`Skipping ${name} — already ${target}`);
skipped++;
continue;
}
if (await exists(output)) {
console.log(`Skipping ${name} — ${basename(output)} already exists`);
skipped++;
continue;
}
try {
const res = await fetch(`${API}/api/av-club/v1/sharp`, {
method: 'POST',
headers,
body: JSON.stringify({
inputPath: input,
outputPath: output,
pipeline: [
/*
`rotate` with no argument applies the EXIF orientation and then drops the tag, so a photo
taken sideways comes out upright. Without it every portrait shot from a phone converts to a
landscape image that every viewer then rotates back — until one does not.
*/
{ rotate: {} },
// `withoutEnlargement` so a thumbnail asked to fit 3000px is left alone rather than blown up.
...(maxDimension
? [{ resize: { width: maxDimension, height: maxDimension, fit: 'inside', withoutEnlargement: true } }]
: []),
],
output: { format: target, options: ENCODING[target] },
}),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
console.error(`Failed ${name}: ${body.error?.message ?? `HTTP ${res.status}`}`);
failed++;
continue;
}
const result = (await res.json()) as { bytes: number };
console.log(`Converted ${name} → ${basename(output)} (${(result.bytes / 1024).toFixed(0)} KB)`);
done++;
/*
Deleted only AFTER a successful write, and only when asked.
av-club writes into a temp file and moves it into place, so reaching this line means the output
is complete and on disk. That ordering is the whole reason deleting is safe to offer at all.
*/
if (deleteSource) {
await unlink(input);
console.log(` removed ${name}`);
}
} catch (error) {
console.error(`Failed ${name}: ${(error as Error).message}`);
failed++;
}
}
console.log('');
console.log(`${done} converted, ${skipped} skipped, ${failed} failed.`);
process.exit(failed > 0 ? 1 : 0);