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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135 | /**
* Build-time EXIF reading.
*
* PRIVACY — read this before changing anything here.
* ---------------------------------------------------------------------------
* This module returns an explicit allowlist of camera fields and nothing else.
* It never returns GPS coordinates, and it must never be changed to. A modern
* phone writes your exact latitude, longitude and altitude into every frame;
* publishing that alongside a photo of your street is how a pseudonymous site
* stops being pseudonymous.
*
* Two independent things protect you, and you want both:
*
* 1. This allowlist, so location data cannot reach a template even by
* accident.
* 2. Astro's image pipeline, which re-encodes every photo through sharp and
* drops all metadata in the process. The .avif and .webp files actually
* served carry no EXIF at all — not even the harmless parts.
*
* The consequence of (2) is that EXIF you want *visible* has to be rendered
* into the HTML as text, which is exactly what this module is for.
*
* Original files still hold their GPS data in your git history. See
* docs/06-privacy.md for stripping them before committing.
*/
import exifr from 'exifr';
export interface PhotoExif {
camera?: string;
lens?: string;
/** e.g. "35 mm" */
focalLength?: string;
/** e.g. "f/1.8" */
aperture?: string;
/** e.g. "1/250 s" */
shutter?: string;
/** e.g. "ISO 400" */
iso?: string;
/** When the shutter fired, not when the file was written. */
taken?: Date;
}
/**
* The only EXIF tags we ever ask the parser for.
*
* exifr only decodes what it is asked for, so GPS blocks are not merely
* discarded downstream — they are never parsed into memory in the first place.
*/
const PICK = [
'Make',
'Model',
'LensModel',
'FocalLength',
'FNumber',
'ExposureTime',
'ISO',
'DateTimeOriginal',
] as const;
/** Anything with an fsPath — Astro's ImageMetadata satisfies this. */
interface HasFsPath {
fsPath?: string;
}
function toPath(input: string | HasFsPath): string | null {
if (typeof input === 'string') return input;
return input?.fsPath ?? null;
}
function formatShutter(seconds: unknown): string | undefined {
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) return undefined;
if (seconds >= 1) return `${Number(seconds.toFixed(1))} s`;
return `1/${Math.round(1 / seconds)} s`;
}
function clean(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length ? trimmed : undefined;
}
/**
* Read camera settings from a photograph.
*
* Returns null rather than throwing for anything at all — a scan with no EXIF,
* a file the parser dislikes, a path that moved. A missing camera readout is a
* cosmetic loss; a failed build over one photo is not acceptable.
*/
export async function readExif(input: string | HasFsPath): Promise<PhotoExif | null> {
const path = toPath(input);
if (!path) return null;
try {
const raw = await exifr.parse(path, { pick: [...PICK] });
if (!raw) return null;
const make = clean(raw.Make);
const model = clean(raw.Model);
// "SONY" + "SONY ILCE-7M3" should not render as "SONY SONY ILCE-7M3".
const camera =
model && make && !model.toUpperCase().startsWith(make.toUpperCase())
? `${make} ${model}`
: (model ?? make);
const focal = typeof raw.FocalLength === 'number' ? `${Math.round(raw.FocalLength)} mm` : undefined;
const aperture = typeof raw.FNumber === 'number' ? `f/${Number(raw.FNumber.toFixed(1))}` : undefined;
const iso = typeof raw.ISO === 'number' ? `ISO ${raw.ISO}` : undefined;
const taken = raw.DateTimeOriginal instanceof Date ? raw.DateTimeOriginal : undefined;
const exif: PhotoExif = {
camera,
lens: clean(raw.LensModel),
focalLength: focal,
aperture,
shutter: formatShutter(raw.ExposureTime),
iso,
taken,
};
// If every field came back empty there is nothing worth rendering.
return Object.values(exif).some((value) => value !== undefined) ? exif : null;
} catch {
return null;
}
}
/** The one-line form: "SONY ILCE-7M3 · 35 mm · f/1.8 · 1/250 s · ISO 400". */
export function exifLine(exif: PhotoExif | null): string | null {
if (!exif) return null;
const parts = [exif.camera, exif.lens, exif.focalLength, exif.aperture, exif.shutter, exif.iso].filter(
(part): part is string => Boolean(part),
);
return parts.length ? parts.join(' · ') : null;
}
|