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>
184 lines
6.5 KiB
TypeScript
184 lines
6.5 KiB
TypeScript
/**
|
|
* 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);
|