From c69f6d12a350e6b7d40db12c63d195fd8390d897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 02:28:06 +0100 Subject: [PATCH] Convert Image, and HEIC opens because av-club already knew how MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- sidecar/index.ts | 1 + sidecar/sharp/run.ts | 50 ++++++++-- tasks/convert-image/TASK.md | 52 ++++++++++ tasks/convert-image/index.ts | 183 +++++++++++++++++++++++++++++++++++ 4 files changed, 278 insertions(+), 8 deletions(-) create mode 100644 tasks/convert-image/TASK.md create mode 100644 tasks/convert-image/index.ts diff --git a/sidecar/index.ts b/sidecar/index.ts index 4fc2945..edc1e17 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -193,6 +193,7 @@ async function route(request: Request): Promise { throw invalid("Expected a JSON body"); })) as SharpRequest; const result = await withSlot(() => runSharp(body)); + if (!("id" in result)) return Response.json(result, { status: 200 }); return Response.json(result, { status: 201 }); } diff --git a/sidecar/sharp/run.ts b/sidecar/sharp/run.ts index 9a29c19..edc3d81 100644 --- a/sidecar/sharp/run.ts +++ b/sidecar/sharp/run.ts @@ -5,9 +5,15 @@ import { contentPath, putAsset, readAsset, type Asset } from "../assets/store"; import { INPUT_OPTIONS, OPERATIONS, OUTPUT_FORMATS } from "./vocabulary"; import { validateObject } from "./validate"; import { decodableInput } from "./decodable"; +import { safeInputPath, safeOutputPath } from "../paths"; +import { stat } from "node:fs/promises"; export type SharpRequest = { 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; pipeline?: 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 * originals are kept (docs/PIPELINE.md: "The original is never destroyed"). */ -export async function runSharp(body: SharpRequest): Promise { - if (typeof body.input !== "string") { - throw invalid("input must be an asset id", "input"); - } +export type SharpResult = Asset | { path: string; bytes: number }; - 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 { + 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"); } @@ -36,7 +58,14 @@ export async function runSharp(body: SharpRequest): Promise { // 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 // 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, { ...inputOptions, @@ -142,5 +171,10 @@ export async function runSharp(body: SharpRequest): Promise { } 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 }; } diff --git a/tasks/convert-image/TASK.md b/tasks/convert-image/TASK.md new file mode 100644 index 0000000..4bbe267 --- /dev/null +++ b/tasks/convert-image/TASK.md @@ -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. diff --git a/tasks/convert-image/index.ts b/tasks/convert-image/index.ts new file mode 100644 index 0000000..657c6b4 --- /dev/null +++ b/tasks/convert-image/index.ts @@ -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 = { 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> = { + 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 = { + 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 { + 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);