eratic / scripts / photos-strip.mjs
529 words
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
#!/usr/bin/env node
/**
 * Last-resort GPS removal for source photographs already in the repository.
 *
 * Prefer stripping location at export time in your photo editor — that path is
 * lossless. This one re-encodes the JPEG, which costs a little quality, and it
 * is the right tool exactly once: when a file with coordinates has already
 * been committed and `npm run photos:check` is refusing to build.
 *
 * Camera fields are deliberately preserved, because they are what the site
 * renders under each frame. Only the GPS block is dropped.
 *
 *   node scripts/photos-strip.mjs             # every offending file
 *   node scripts/photos-strip.mjs path/to.jpg # one file
 *
 * Note that this rewrites the working copy only. The coordinates remain in
 * git history for any commit that already contained them — see
 * docs/06-privacy.md if that matters to you.
 */

import { readdir, rename, stat } from 'node:fs/promises';
import { join, relative, extname } from 'node:path';
import exifr from 'exifr';
import sharp from 'sharp';

const ROOT = new URL('..', import.meta.url).pathname;
const IMAGE_EXT = new Set(['.jpg', '.jpeg']);
const SKIP_DIR = new Set(['node_modules', 'dist', '.astro', '.git']);

async function walk(dir, found = []) {
	let entries;
	try {
		entries = await readdir(dir, { withFileTypes: true });
	} catch {
		return found;
	}
	for (const entry of entries) {
		if (entry.isDirectory()) {
			if (SKIP_DIR.has(entry.name)) continue;
			await walk(join(dir, entry.name), found);
		} else if (IMAGE_EXT.has(extname(entry.name).toLowerCase())) {
			found.push(join(dir, entry.name));
		}
	}
	return found;
}

const explicit = process.argv.slice(2);
const files = explicit.length ? explicit : await walk(join(ROOT, 'sites'));

let fixed = 0;

for (const file of files) {
	const gps = await exifr.gps(file).catch(() => null);
	if (!gps?.latitude && !explicit.length) continue;

	// Read back the camera fields worth keeping, then write a fresh EXIF block
	// containing only those.
	const meta = await exifr
		.parse(file, {
			pick: [
				'Make',
				'Model',
				'LensModel',
				'FocalLength',
				'FNumber',
				'ExposureTime',
				'ISO',
				'DateTimeOriginal',
			],
		})
		.catch(() => ({}));

	// sharp names the IFDs positionally: IFD0 is the main block, IFD2 is the
	// Exif sub-IFD where the exposure fields live, IFD3 is GPS. Using the
	// friendlier-looking "ExifIFD" here is silently ignored — the file writes
	// successfully and every camera setting is gone.
	const exif = { IFD0: {}, IFD2: {} };
	if (meta?.Make) exif.IFD0.Make = String(meta.Make);
	if (meta?.Model) exif.IFD0.Model = String(meta.Model);
	if (meta?.LensModel) exif.IFD2.LensModel = String(meta.LensModel);
	if (meta?.FocalLength) exif.IFD2.FocalLength = String(meta.FocalLength);
	if (meta?.FNumber) exif.IFD2.FNumber = String(meta.FNumber);
	if (meta?.ExposureTime) exif.IFD2.ExposureTime = String(meta.ExposureTime);
	// exiv2's name for the tag, which is what sharp writes against. Writing
	// `ISO` here produces a file with no ISO in it and no error.
	if (meta?.ISO) exif.IFD2.ISOSpeedRatings = String(meta.ISO);
	if (meta?.DateTimeOriginal instanceof Date) {
		const d = meta.DateTimeOriginal;
		const pad = (n) => String(n).padStart(2, '0');
		exif.IFD2.DateTimeOriginal =
			`${d.getFullYear()}:${pad(d.getMonth() + 1)}:${pad(d.getDate())} ` +
			`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
	}

	const tmp = `${file}.stripped`;
	// Quality 95 with mozjpeg: a single re-encode at this setting is visually
	// indistinguishable, and it only ever happens once per file.
	await sharp(file).jpeg({ quality: 95, mozjpeg: true }).withExif(exif).toFile(tmp);
	await rename(tmp, file);

	const { size } = await stat(file);
	console.log(`✓ ${relative(ROOT, file)} — GPS removed, camera fields kept (${(size / 1024).toFixed(0)} kB)`);
	fixed += 1;
}

console.log(fixed === 0 ? 'Nothing to strip.' : `\nStripped ${fixed} file(s).`);

static mirror of HEAD · about · clone: git clone https://git.makefuture.ro/eratic.git