/** * 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);