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:
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user