Skip to content

Commit 39ff258

Browse files
committed
s3 originals + camera EXIF metadata
scripts/process-staging.mjs: - reads full EXIF (camera, lens, ISO, aperture, shutter, focal length, dimensions) in addition to date/GPS - scrubs the original (strips all EXIF including GPS via sharp's default behavior) - uploads scrubbed full-size original to s3://georgemain-com-media/<gallery>/<file> - writes a smaller 1400px local reference into content/media/ for build-time thumb/med generation (keeps repo lean) - metadata.yaml entries now carry { file, s3, alt, title, date, location, exif } templates/media-photo.eta: - camera/lens/settings/dimensions table when exif present - "view full size ↗" link to the S3 original when s3 is set, falls back to local file for older entries
1 parent 76dff3a commit 39ff258

3 files changed

Lines changed: 91 additions & 19 deletions

File tree

AWSCLIV2.pkg

52.9 MB
Binary file not shown.

scripts/process-staging.mjs

Lines changed: 79 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
// Required answer per image: alt text. Build refuses to ship without it.
1313

1414
import { promises as fs } from 'node:fs';
15+
import { execFileSync } from 'node:child_process';
1516
import path from 'node:path';
17+
import os from 'node:os';
1618
import readline from 'node:readline/promises';
1719
import { stdin, stdout } from 'node:process';
1820
import { fileURLToPath } from 'node:url';
@@ -21,6 +23,9 @@ import yaml from 'js-yaml';
2123
import fg from 'fast-glob';
2224
import exifr from 'exifr';
2325

26+
const S3_BUCKET = 'georgemain-com-media';
27+
const S3_BASE_URL = `https://${S3_BUCKET}.s3.amazonaws.com`;
28+
2429
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
2530
const STAGING = path.join(ROOT, 'staging');
2631
const MEDIA = path.join(ROOT, 'content/media');
@@ -44,22 +49,45 @@ async function listStaging() {
4449
.sort((a, b) => a.name.localeCompare(b.name));
4550
}
4651

47-
// ── read EXIF for date + GPS hint ─────────────────────────
52+
// ── read EXIF for date + GPS + camera details ─────────────
53+
function formatShutter(t) {
54+
if (t == null) return null;
55+
if (t >= 1) return t + 's';
56+
return '1/' + Math.round(1 / t);
57+
}
4858
async function readExif(abs) {
4959
try {
50-
const d = await exifr.parse(abs, { gps: true, tiff: true });
60+
const d = await exifr.parse(abs, { gps: true, tiff: true, exif: true });
61+
const camera = [d?.Make, d?.Model].filter(Boolean).join(' ').trim() || null;
5162
return {
5263
date: d?.DateTimeOriginal || d?.CreateDate || d?.DateTime || null,
5364
lat: d?.latitude || null,
5465
lon: d?.longitude || null,
55-
make: d?.Make || null,
56-
model: d?.Model || null,
66+
camera,
67+
lens: d?.LensModel || d?.LensMake || null,
68+
iso: d?.ISO || null,
69+
aperture: d?.FNumber ? 'f/' + d.FNumber : null,
70+
shutter: formatShutter(d?.ExposureTime),
71+
focal_length: d?.FocalLength ? Math.round(d.FocalLength) + 'mm' : null,
72+
width: d?.ExifImageWidth || d?.ImageWidth || null,
73+
height: d?.ExifImageHeight || d?.ImageHeight || null,
5774
};
5875
} catch {
5976
return { date: null, lat: null, lon: null };
6077
}
6178
}
6279

80+
// ── S3 upload via aws CLI ─────────────────────────────────
81+
function uploadToS3(localPath, key) {
82+
execFileSync('aws', [
83+
's3', 'cp', localPath, `s3://${S3_BUCKET}/${key}`,
84+
'--content-type', 'image/jpeg',
85+
'--cache-control', 'public, max-age=31536000, immutable',
86+
'--no-progress',
87+
], { stdio: ['ignore', 'ignore', 'inherit'] });
88+
return `${S3_BASE_URL}/${key}`;
89+
}
90+
6391
// ── group files into bursts ───────────────────────────────
6492
// A burst = same alpha prefix in filename + capture timestamps within 30 min
6593
// of the previous. If no EXIF, fall back to filename adjacency.
@@ -146,16 +174,22 @@ function nextImgName(meta, ext) {
146174
return `img_${String(n).padStart(3, '0')}${ext}`;
147175
}
148176

149-
// ── scrub + write image ───────────────────────────────────
150-
async function scrubAndWrite(srcAbs, destAbs) {
177+
// ── scrub original (full size) to a temp file ─────────────
178+
// Returns the temp file path; caller should unlink when done.
179+
async function scrubToTemp(srcAbs) {
180+
const tmp = path.join(os.tmpdir(), `staging-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`);
181+
await sharp(srcAbs).rotate().jpeg({ quality: 92, progressive: true, mozjpeg: true }).toFile(tmp);
182+
return tmp;
183+
}
184+
185+
// ── write a smaller local reference (used by build for thumbs) ──
186+
async function writeLocalReference(srcAbs, destAbs) {
151187
await fs.mkdir(path.dirname(destAbs), { recursive: true });
152-
const ext = path.extname(destAbs).toLowerCase();
153-
let pipeline = sharp(srcAbs).rotate(); // bake orientation then drop EXIF
154-
// default sharp behavior: strip metadata. No .withMetadata() call.
155-
if (ext === '.png') pipeline = pipeline.png();
156-
else if (ext === '.webp') pipeline = pipeline.webp({ quality: 88 });
157-
else pipeline = pipeline.jpeg({ quality: 88, progressive: true, mozjpeg: true });
158-
await pipeline.toFile(destAbs);
188+
await sharp(srcAbs)
189+
.rotate()
190+
.resize({ width: 1400, withoutEnlargement: true })
191+
.jpeg({ quality: 82, progressive: true, mozjpeg: true })
192+
.toFile(destAbs);
159193
}
160194

161195
// ── main flow ─────────────────────────────────────────────
@@ -227,25 +261,52 @@ async function main() {
227261
console.log(' alt text is required — keeps the site accessible and SEO-readable.');
228262
}
229263

230-
const ext = path.extname(f.name).toLowerCase();
231-
const out = rename ? nextImgName(meta, ext) : f.name;
264+
const ext = '.jpg'; // we normalize everything to jpeg on upload
265+
const out = rename ? nextImgName(meta, ext) : f.name.replace(/\.[^.]+$/, ext);
266+
267+
// 1) scrub full-size original to a temp file
268+
const scrubbed = await scrubToTemp(f.abs);
269+
const fullBytes = (await fs.stat(scrubbed)).size;
270+
271+
// 2) upload scrubbed full-size to S3
272+
const s3Key = `${galleryPath}/${out}`;
273+
process.stdout.write(` ↑ uploading to s3://${S3_BUCKET}/${s3Key} (${(fullBytes/1024/1024).toFixed(1)} MB)…`);
274+
const s3Url = uploadToS3(scrubbed, s3Key);
275+
console.log(' ✓');
276+
277+
// 3) write smaller local reference (1400px) for build-time thumb/med generation
232278
const destAbs = path.join(MEDIA, galleryPath, out);
233-
await scrubAndWrite(f.abs, destAbs);
279+
await writeLocalReference(scrubbed, destAbs);
280+
281+
// 4) clean up temp + staging
282+
await fs.unlink(scrubbed);
234283
await fs.unlink(f.abs);
235284

285+
// 5) build metadata entry — EXIF kept separate from photo
286+
const exifBlock = {};
287+
if (ex?.camera) exifBlock.camera = ex.camera;
288+
if (ex?.lens) exifBlock.lens = ex.lens;
289+
if (ex?.iso) exifBlock.iso = ex.iso;
290+
if (ex?.aperture) exifBlock.aperture = ex.aperture;
291+
if (ex?.shutter) exifBlock.shutter = ex.shutter;
292+
if (ex?.focal_length) exifBlock.focal_length = ex.focal_length;
293+
if (ex?.width) exifBlock.width = ex.width;
294+
if (ex?.height) exifBlock.height = ex.height;
295+
236296
const entry = {
237297
file: out,
238298
...(title && { title }),
239299
date: batchDate || undefined,
240300
...(batchLocation && { location: batchLocation }),
241301
alt,
302+
s3: s3Url,
303+
...(Object.keys(exifBlock).length ? { exif: exifBlock } : {}),
242304
};
243-
// strip undefined
244305
Object.keys(entry).forEach(k => entry[k] === undefined && delete entry[k]);
245306

246307
meta.images = (meta.images || []).concat(entry);
247308
await saveMeta(galleryPath, meta);
248-
console.log(` ✓ -> content/media/${galleryPath}/${out}`);
309+
console.log(` ✓ -> content/media/${galleryPath}/${out} + s3 original`);
249310
}
250311
}
251312

templates/media-photo.eta

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,18 @@
1919
<% if (image.date) { %><tr><td>date</td><td><%= fmtDate(image.date) %></td></tr><% } %>
2020
<% if (image.location || node.location) { %><tr><td>where</td><td><%= image.location || node.location %></td></tr><% } %>
2121
<tr><td>album</td><td><a href="<%= node.url %>"><%= node.title %></a></td></tr>
22-
<tr><td>file</td><td><a href="<%= image.src %>"><%= image.file %></a></td></tr>
22+
<% if (image.exif) { %>
23+
<% if (image.exif.camera) { %><tr><td>camera</td><td><%= image.exif.camera %></td></tr><% } %>
24+
<% if (image.exif.lens) { %><tr><td>lens</td><td><%= image.exif.lens %></td></tr><% } %>
25+
<% var settings = [image.exif.focal_length, image.exif.aperture, image.exif.shutter, image.exif.iso && 'ISO ' + image.exif.iso].filter(Boolean).join(' · '); %>
26+
<% if (settings) { %><tr><td>settings</td><td><%= settings %></td></tr><% } %>
27+
<% if (image.exif.width && image.exif.height) { %><tr><td>dimensions</td><td><%= image.exif.width %> × <%= image.exif.height %></td></tr><% } %>
28+
<% } %>
29+
<% if (image.s3) { %>
30+
<tr><td>original</td><td><a href="<%= image.s3 %>">view full size ↗</a></td></tr>
31+
<% } else { %>
32+
<tr><td>file</td><td><a href="<%= image.src %>"><%= image.file %></a></td></tr>
33+
<% } %>
2334
</tbody>
2435
</table>
2536
</div>

0 commit comments

Comments
 (0)