Last active
June 23, 2026 14:04
-
-
Save romellem/92bacef495b3f5c2f2df6dc93b54790a to your computer and use it in GitHub Desktop.
Prefix images with aspect ratio
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| import { execSync } from 'node:child_process'; | |
| import { | |
| readdirSync, | |
| statSync, | |
| unlinkSync, | |
| existsSync, | |
| mkdirSync, | |
| copyFileSync, | |
| renameSync, | |
| } from 'node:fs'; | |
| import { basename, extname, dirname, join, resolve } from 'node:path'; | |
| import { parseArgs } from 'node:util'; | |
| const IMAGE_EXTS = new Set([ | |
| '.jpg', | |
| '.jpeg', | |
| '.png', | |
| '.webp', | |
| '.gif', | |
| '.tiff', | |
| '.tif', | |
| '.bmp', | |
| '.heic', | |
| '.heif', | |
| ]); | |
| const JPEG_EXTS = new Set(['.jpg', '.jpeg']); | |
| const PREFIX_RE = /^[svh][_-]/; | |
| const USAGE = ` | |
| Usage: node categorize-test-images.mjs <files/dirs...> [options] | |
| Reads images that lack an orientation prefix and renames them with the | |
| appropriate prefix (e.g. h_3-2_, v_9-16_, s_). Always outputs JPEG. | |
| The aspect ratio is always included in the prefix (except for square images). | |
| Without --aspectRatios the native ratio is computed from pixel dimensions. | |
| When no processing is needed (already JPEG, correct ratio, within size | |
| limits), the file is renamed/copied without re-encoding. | |
| Options: | |
| --maxWidth=N Resize down so width ≤ N (preserves aspect ratio) | |
| --maxHeight=N Resize down so height ≤ N (preserves aspect ratio) | |
| --aspectRatios=A:B,.. Comma-separated valid ratios, e.g. 1:1,3:2,9:16 | |
| --quality=N JPEG quality 1–100 (default: 85) | |
| --overwrite Replace the original file (default) | |
| --no-overwrite Keep the original; write the prefixed output alongside it | |
| --output=DIR Write all output files to DIR instead of next to the | |
| source image. Cannot be combined with --overwrite. | |
| --quiet Suppress all output (non-zero exit code on error) | |
| --dryRun Print actions without writing files | |
| --help Show this help | |
| Examples: | |
| node categorize-test-images.mjs unmarked/ | |
| node categorize-test-images.mjs unmarked/ --maxWidth=2000 --aspectRatios=1:1,3:2,9:16 | |
| node categorize-test-images.mjs unmarked/ --no-overwrite --output=out/ | |
| node categorize-test-images.mjs img1.jpg img2.png --quality=90 --dryRun | |
| `.trim(); | |
| // ─── image collection ──────────────────────────────────────────────────────── | |
| function collectImages(paths) { | |
| const images = []; | |
| for (const p of paths) { | |
| const resolved = resolve(p); | |
| if (!existsSync(resolved)) { | |
| console.warn(`Warning: path not found, skipping: ${p}`); | |
| continue; | |
| } | |
| const stat = statSync(resolved); | |
| if (stat.isDirectory()) { | |
| for (const entry of readdirSync(resolved)) { | |
| const full = join(resolved, entry); | |
| if (statSync(full).isFile() && IMAGE_EXTS.has(extname(entry).toLowerCase())) { | |
| images.push(full); | |
| } | |
| } | |
| } else if (stat.isFile()) { | |
| if (IMAGE_EXTS.has(extname(resolved).toLowerCase())) { | |
| images.push(resolved); | |
| } else { | |
| console.warn(`Warning: not a recognised image type, skipping: ${p}`); | |
| } | |
| } | |
| } | |
| return images; | |
| } | |
| function isUnprefixed(filePath) { | |
| return !PREFIX_RE.test(basename(filePath)); | |
| } | |
| // ─── imagemagick helpers ────────────────────────────────────────────────────── | |
| function getImageDimensions(filePath) { | |
| const out = execSync(`magick identify -format "%w %h" ${JSON.stringify(filePath)}`, { | |
| encoding: 'utf8', | |
| }); | |
| const parts = out.trim().split(/\s+/); | |
| if (parts.length < 2) throw new Error(`identify returned unexpected output: ${out}`); | |
| return { width: parseInt(parts[0], 10), height: parseInt(parts[1], 10) }; | |
| } | |
| // ─── ratio logic ────────────────────────────────────────────────────────────── | |
| function gcd(a, b) { | |
| return b === 0 ? a : gcd(b, a % b); | |
| } | |
| // Derive a human-readable ratio from raw pixel dimensions. | |
| // Uses GCD reduction when it produces small integers (≤ 30 each); | |
| // otherwise expresses as a decimal:1 ratio (matching the h_1.91-1_ convention). | |
| function computeNativeRatio(width, height) { | |
| const g = gcd(width, height); | |
| const a = width / g; | |
| const b = height / g; | |
| if (a <= 30 && b <= 30) { | |
| return { large: Math.max(a, b), small: Math.min(a, b) }; | |
| } | |
| const decimal = parseFloat((Math.max(width, height) / Math.min(width, height)).toFixed(2)); | |
| return { large: decimal, small: 1 }; | |
| } | |
| function parseAspectRatios(str) { | |
| return str.split(',').map((r) => { | |
| const parts = r.trim().split(':'); | |
| if (parts.length !== 2) throw new Error(`Invalid aspect ratio: ${r}`); | |
| const a = parseFloat(parts[0]); | |
| const b = parseFloat(parts[1]); | |
| if (isNaN(a) || isNaN(b) || a <= 0 || b <= 0) | |
| throw new Error(`Invalid aspect ratio values: ${r}`); | |
| return { large: Math.max(a, b), small: Math.min(a, b) }; | |
| }); | |
| } | |
| function findClosestRatio(width, height, ratios) { | |
| const actualRatio = width / height; | |
| const isHorizontal = width >= height; | |
| let closest = null; | |
| let minDiff = Infinity; | |
| for (const { large, small } of ratios) { | |
| const targetRatio = isHorizontal ? large / small : small / large; | |
| const diff = Math.abs(actualRatio - targetRatio); | |
| if (diff < minDiff) { | |
| minDiff = diff; | |
| closest = { large, small }; | |
| } | |
| } | |
| return closest; | |
| } | |
| function calculateCropDimensions(width, height, targetA, targetB) { | |
| const targetRatio = targetA / targetB; | |
| const actualRatio = width / height; | |
| if (Math.abs(actualRatio - targetRatio) < 0.005) return null; | |
| if (actualRatio > targetRatio) { | |
| return { cropW: Math.floor(height * targetRatio), cropH: height }; | |
| } else { | |
| return { cropW: width, cropH: Math.floor(width / targetRatio) }; | |
| } | |
| } | |
| // ─── naming ─────────────────────────────────────────────────────────────────── | |
| function formatRatioNumber(n) { | |
| return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.?0+$/, ''); | |
| } | |
| function buildPrefix(width, height, ratio) { | |
| if (width === height) return 's_'; | |
| if (!ratio) return width > height ? 'h_' : 'v_'; | |
| const { large, small } = ratio; | |
| return width > height | |
| ? `h_${formatRatioNumber(large)}-${formatRatioNumber(small)}_` | |
| : `v_${formatRatioNumber(small)}-${formatRatioNumber(large)}_`; | |
| } | |
| function buildOutputPath(inputPath, prefix, outputDir) { | |
| const dir = outputDir ?? dirname(inputPath); | |
| const name = basename(inputPath, extname(inputPath)); | |
| return join(dir, `${prefix}${name}.jpg`); | |
| } | |
| // ─── processing ─────────────────────────────────────────────────────────────── | |
| function buildConvertArgs(inputPath, outputPath, cropDims, maxWidth, maxHeight, quality) { | |
| const args = [JSON.stringify(inputPath)]; | |
| if (cropDims) { | |
| args.push( | |
| '-gravity', | |
| 'Center', | |
| '-crop', | |
| `${cropDims.cropW}x${cropDims.cropH}+0+0`, | |
| '+repage', | |
| ); | |
| } | |
| if (maxWidth || maxHeight) { | |
| args.push('-resize', `'${maxWidth || ''}x${maxHeight || ''}>'`); | |
| } | |
| args.push('-quality', String(quality)); | |
| args.push(JSON.stringify(outputPath)); | |
| return args; | |
| } | |
| function formatBytes(bytes) { | |
| if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`; | |
| if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`; | |
| return `${bytes} B`; | |
| } | |
| // Returns { renameOnly, inputSize, outputSize } or null on error. | |
| // outputSize is null during a dry run. | |
| function processImage(inputPath, opts) { | |
| const { maxWidth, maxHeight, ratios, quality, overwrite, outputDir, dryRun, log } = opts; | |
| let width, height; | |
| try { | |
| ({ width, height } = getImageDimensions(inputPath)); | |
| } catch (err) { | |
| console.error(`Error reading dimensions for ${inputPath}: ${err.message}`); | |
| return null; | |
| } | |
| let ratio = null; | |
| let cropDims = null; | |
| if (ratios && ratios.length > 0) { | |
| ratio = findClosestRatio(width, height, ratios); | |
| if (ratio) { | |
| const isHorizontal = width >= height; | |
| const targetA = isHorizontal ? ratio.large : ratio.small; | |
| const targetB = isHorizontal ? ratio.small : ratio.large; | |
| cropDims = calculateCropDimensions(width, height, targetA, targetB); | |
| } | |
| } else if (width !== height) { | |
| ratio = computeNativeRatio(width, height); | |
| } | |
| const needsResize = (maxWidth && width > maxWidth) || (maxHeight && height > maxHeight); | |
| const isJpeg = JPEG_EXTS.has(extname(inputPath).toLowerCase()); | |
| const renameOnly = isJpeg && !cropDims && !needsResize; | |
| const outW = cropDims ? cropDims.cropW : width; | |
| const outH = cropDims ? cropDims.cropH : height; | |
| const prefix = buildPrefix(outW, outH, ratio); | |
| const outputPath = buildOutputPath(inputPath, prefix, outputDir); | |
| const inputSize = statSync(inputPath).size; | |
| const tag = renameOnly ? 'rename' : dryRun ? 'dry-run' : 'encode'; | |
| const label = `${basename(inputPath)} → ${basename(outputPath)}`; | |
| const detail = renameOnly | |
| ? `${width}×${height}` | |
| : [ | |
| `${width}×${height}`, | |
| cropDims ? `crop→${cropDims.cropW}×${cropDims.cropH}` : null, | |
| needsResize ? `resize≤${maxWidth || '∞'}×${maxHeight || '∞'}` : null, | |
| `q=${quality}`, | |
| ] | |
| .filter(Boolean) | |
| .join(', '); | |
| log(`[${tag}] ${label} (${detail})`); | |
| if (dryRun) return { renameOnly, inputSize, outputSize: null }; | |
| if (renameOnly) { | |
| try { | |
| if (overwrite) { | |
| renameSync(inputPath, outputPath); | |
| } else { | |
| copyFileSync(inputPath, outputPath); | |
| } | |
| } catch (err) { | |
| console.error(`Error renaming ${inputPath}: ${err.message}`); | |
| return null; | |
| } | |
| } else { | |
| const args = buildConvertArgs( | |
| inputPath, | |
| outputPath, | |
| cropDims, | |
| maxWidth, | |
| maxHeight, | |
| quality, | |
| ); | |
| try { | |
| execSync(`magick ${args.join(' ')}`, { stdio: 'pipe' }); | |
| } catch (err) { | |
| console.error(`Error encoding ${inputPath}: ${err.message}`); | |
| return null; | |
| } | |
| if (overwrite && existsSync(outputPath)) { | |
| try { | |
| unlinkSync(inputPath); | |
| } catch (err) { | |
| console.warn(`Warning: could not remove original ${inputPath}: ${err.message}`); | |
| } | |
| } | |
| } | |
| const outputSize = existsSync(outputPath) ? statSync(outputPath).size : inputSize; | |
| return { renameOnly, inputSize, outputSize }; | |
| } | |
| // ─── main ───────────────────────────────────────────────────────────────────── | |
| function main() { | |
| // parseArgs doesn't support --no-* negation natively, so handle it manually. | |
| const rawArgv = process.argv.slice(2); | |
| const hasOverwrite = rawArgv.includes('--overwrite'); | |
| const hasNoOverwrite = rawArgv.includes('--no-overwrite'); | |
| if (hasOverwrite && hasNoOverwrite) { | |
| console.error('Error: --overwrite and --no-overwrite cannot both be specified'); | |
| process.exit(1); | |
| } | |
| const overwrite = !hasNoOverwrite; | |
| const filteredArgv = rawArgv.filter((a) => a !== '--overwrite' && a !== '--no-overwrite'); | |
| const { values, positionals } = parseArgs({ | |
| args: filteredArgv, | |
| allowPositionals: true, | |
| options: { | |
| maxWidth: { type: 'string' }, | |
| maxHeight: { type: 'string' }, | |
| aspectRatios: { type: 'string' }, | |
| quality: { type: 'string', default: '85' }, | |
| output: { type: 'string' }, | |
| quiet: { type: 'boolean', default: false }, | |
| dryRun: { type: 'boolean', default: false }, | |
| help: { type: 'boolean', default: false }, | |
| }, | |
| }); | |
| if (values.help || positionals.length === 0) { | |
| console.log(USAGE); | |
| process.exit(values.help ? 0 : 1); | |
| } | |
| const quiet = values.quiet; | |
| const log = quiet ? () => {} : console.log; | |
| try { | |
| execSync('magick --version', { stdio: 'ignore' }); | |
| } catch { | |
| console.error( | |
| 'Error: ImageMagick is not installed or not in PATH. Install with: brew install imagemagick', | |
| ); | |
| process.exit(1); | |
| } | |
| const maxWidth = values.maxWidth ? parseInt(values.maxWidth, 10) : null; | |
| const maxHeight = values.maxHeight ? parseInt(values.maxHeight, 10) : null; | |
| const quality = parseInt(values.quality, 10); | |
| const dryRun = values.dryRun; | |
| const outputDir = values.output ? resolve(values.output) : null; | |
| if (outputDir && overwrite) { | |
| console.error( | |
| 'Error: --output and --overwrite cannot be used together.\n' + | |
| 'Use --no-overwrite when redirecting output to a different directory.', | |
| ); | |
| process.exit(1); | |
| } | |
| if (isNaN(quality) || quality < 1 || quality > 100) { | |
| console.error('Error: --quality must be a number between 1 and 100'); | |
| process.exit(1); | |
| } | |
| if (maxWidth !== null && (isNaN(maxWidth) || maxWidth <= 0)) { | |
| console.error('Error: --maxWidth must be a positive integer'); | |
| process.exit(1); | |
| } | |
| if (maxHeight !== null && (isNaN(maxHeight) || maxHeight <= 0)) { | |
| console.error('Error: --maxHeight must be a positive integer'); | |
| process.exit(1); | |
| } | |
| let ratios = null; | |
| if (values.aspectRatios) { | |
| try { | |
| ratios = parseAspectRatios(values.aspectRatios); | |
| } catch (err) { | |
| console.error(`Error parsing --aspectRatios: ${err.message}`); | |
| process.exit(1); | |
| } | |
| } | |
| const allImages = collectImages(positionals); | |
| const unprefixed = allImages.filter((p) => isUnprefixed(basename(p))); | |
| if (unprefixed.length === 0) { | |
| log('No unprefixed images found.'); | |
| return; | |
| } | |
| if (outputDir && !dryRun) mkdirSync(outputDir, { recursive: true }); | |
| log(`Found ${unprefixed.length} unprefixed image(s) to process.\n`); | |
| const results = unprefixed | |
| .map((imgPath) => | |
| processImage(imgPath, { | |
| maxWidth, | |
| maxHeight, | |
| ratios, | |
| quality, | |
| overwrite, | |
| outputDir, | |
| dryRun, | |
| log, | |
| }), | |
| ) | |
| .filter((r) => r !== null); | |
| // ── summary ──────────────────────────────────────────────────────────────── | |
| const renamed = results.filter((r) => r.renameOnly).length; | |
| const encoded = results.filter((r) => !r.renameOnly).length; | |
| const totalIn = results.reduce((s, r) => s + r.inputSize, 0); | |
| const totalOut = results.reduce((s, r) => s + (r.outputSize ?? r.inputSize), 0); | |
| const hasSizes = results.some((r) => r.outputSize !== null); | |
| const pctChange = totalIn > 0 ? Math.round(((totalOut - totalIn) / totalIn) * 100) : 0; | |
| const sign = pctChange <= 0 ? '' : '+'; | |
| log('\n' + '─'.repeat(48)); | |
| if (dryRun) { | |
| log(`Dry run — ${results.length} image(s) would be processed`); | |
| } else { | |
| log(`Processed ${results.length} image(s)`); | |
| } | |
| if (renamed > 0 || encoded > 0) { | |
| if (renamed > 0) log(` Renamed (no re-encode): ${renamed}`); | |
| if (encoded > 0) log(` Re-encoded: ${encoded}`); | |
| } | |
| if (hasSizes && totalIn > 0) { | |
| log(` Size: ${formatBytes(totalIn)} → ${formatBytes(totalOut)} (${sign}${pctChange}%)`); | |
| } | |
| } | |
| main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment