Skip to content

Instantly share code, notes, and snippets.

@romellem
Last active June 23, 2026 14:04
Show Gist options
  • Select an option

  • Save romellem/92bacef495b3f5c2f2df6dc93b54790a to your computer and use it in GitHub Desktop.

Select an option

Save romellem/92bacef495b3f5c2f2df6dc93b54790a to your computer and use it in GitHub Desktop.
Prefix images with aspect ratio
#!/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