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:
@@ -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