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).`);
|