1212// Required answer per image: alt text. Build refuses to ship without it.
1313
1414import { promises as fs } from 'node:fs' ;
15+ import { execFileSync } from 'node:child_process' ;
1516import path from 'node:path' ;
17+ import os from 'node:os' ;
1618import readline from 'node:readline/promises' ;
1719import { stdin , stdout } from 'node:process' ;
1820import { fileURLToPath } from 'node:url' ;
@@ -21,6 +23,9 @@ import yaml from 'js-yaml';
2123import fg from 'fast-glob' ;
2224import exifr from 'exifr' ;
2325
26+ const S3_BUCKET = 'georgemain-com-media' ;
27+ const S3_BASE_URL = `https://${ S3_BUCKET } .s3.amazonaws.com` ;
28+
2429const ROOT = path . resolve ( path . dirname ( fileURLToPath ( import . meta. url ) ) , '..' ) ;
2530const STAGING = path . join ( ROOT , 'staging' ) ;
2631const 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+ }
4858async 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
0 commit comments