|
| 1 | +// One-off: upload all 5 outback/mona galleries with proper slugs + alt text. |
| 2 | +import { promises as fs } from 'node:fs'; |
| 3 | +import { execFileSync } from 'node:child_process'; |
| 4 | +import path from 'node:path'; |
| 5 | +import os from 'node:os'; |
| 6 | +import { fileURLToPath } from 'node:url'; |
| 7 | +import sharp from 'sharp'; |
| 8 | +import yaml from 'js-yaml'; |
| 9 | +import exifr from 'exifr'; |
| 10 | + |
| 11 | +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); |
| 12 | +const BUCKET = 'georgemain-com-media'; |
| 13 | +const BASE = `https://${BUCKET}.s3.amazonaws.com`; |
| 14 | + |
| 15 | +const formatShutter = (t) => t == null ? null : t >= 1 ? t + 's' : '1/' + Math.round(1/t); |
| 16 | + |
| 17 | +function s3up(local, key) { |
| 18 | + execFileSync('aws', ['s3', 'cp', local, `s3://${BUCKET}/${key}`, |
| 19 | + '--content-type', 'image/jpeg', |
| 20 | + '--cache-control', 'public, max-age=31536000, immutable', |
| 21 | + '--no-progress'], { stdio: ['ignore', 'ignore', 'inherit'] }); |
| 22 | + return `${BASE}/${key}`; |
| 23 | +} |
| 24 | + |
| 25 | +const GALLERIES = [ |
| 26 | + { |
| 27 | + folder: 'uluru_outback', path: 'australia/outback/uluru', |
| 28 | + title: 'Uluru', subtitle: 'Ayers Rock at every hour of the day.', |
| 29 | + location: 'Uluru-Kata Tjuta National Park, NT, Australia', |
| 30 | + desc: 'Photos of Uluru (Ayers Rock) from midday through sunset.', |
| 31 | + keywords: ['Uluru', 'Ayers Rock', 'Outback', 'Northern Territory'], |
| 32 | + photos: [ |
| 33 | + ['DSC03149.JPG', 'uluru-wide-daytime', 'Uluru from a distance, midday.'], |
| 34 | + ['DSC03282.JPG', 'uluru-sunset', 'Uluru glowing red at sunset.'], |
| 35 | + ['DSC03295.JPG', 'uluru-cliff-detail', "Cliff face of Uluru at sunset, close-up."], |
| 36 | + ['DSC03308.JPG', 'uluru-profile', 'Side profile of Uluru in evening light.'], |
| 37 | + ['DSC03313.JPG', 'outback-sky-clouds', 'Dramatic clouds with sun breaking through.'], |
| 38 | + ['DSC03326.JPG', 'adventure-tours-bus', 'The Adventure Tours bus.'], |
| 39 | + ['DSC03330.JPG', 'uluru-behind-grass', 'Uluru visible above tall grass and a fence.'], |
| 40 | + ['DSC03337.JPG', 'red-dirt-tire-tracks', 'Tire tracks in red outback dirt.'], |
| 41 | + ['DSC03342.JPG', 'uluru-from-viewpoint', 'Uluru framed by grass at a viewpoint.'], |
| 42 | + ['DSC03346.JPG', 'uluru-dusk', 'Uluru at dusk from the viewing area.'], |
| 43 | + ['DSC03347.JPG', 'uluru-cliff-face-close', "Uluru's cliff face at sunset, close."], |
| 44 | + ['DSC03370.JPG', 'uluru-distant-dusk', 'Uluru in the distance at dusk.'], |
| 45 | + ['DSC03383.JPG', 'outback-sunset', 'Sunset over the outback.'], |
| 46 | + ], |
| 47 | + }, |
| 48 | + { |
| 49 | + folder: 'olgas_outback', path: 'australia/outback/kata-tjuta', |
| 50 | + title: 'Kata Tjuta (The Olgas)', subtitle: 'The Valley of the Winds.', |
| 51 | + location: 'Uluru-Kata Tjuta National Park, NT, Australia', |
| 52 | + desc: 'Photos of Kata Tjuta (The Olgas) — the domed rock formations west of Uluru.', |
| 53 | + keywords: ['Kata Tjuta', 'The Olgas', 'Valley of the Winds', 'Outback'], |
| 54 | + photos: [ |
| 55 | + ['DSC03146.JPG', 'kata-tjuta-from-lookout', 'The domes of Kata Tjuta from a distant lookout.'], |
| 56 | + ['DSC03177.JPG', 'valley-of-the-winds-entry','Hikers walking between two domes at the entry.'], |
| 57 | + ['DSC03183.JPG', 'valley-of-the-winds-trail','Hikers heading up the Valley of the Winds trail.'], |
| 58 | + ['DSC03184.JPG', 'pitted-rock-wall', 'Pock-marked surface of a Kata Tjuta dome.'], |
| 59 | + ['DSC03187.JPG', 'gum-tree-against-rock', 'A white-trunked gum tree against the red rock.'], |
| 60 | + ['DSC03190.JPG', 'conglomerate-texture', 'Close-up of the red conglomerate rock texture.'], |
| 61 | + ['DSC03194.JPG', 'talus-slope', 'Loose talus rock on a slope.'], |
| 62 | + ['DSC03195.JPG', 'red-scree', 'Red scree slope below a dome.'], |
| 63 | + ['DSC03196.JPG', 'split-boulder', 'A large boulder split open.'], |
| 64 | + ['DSC03197.JPG', 'trail-into-valley', 'Stairs and trail leading into the Valley of the Winds.'], |
| 65 | + ['DSC03198.JPG', 'hikers-on-trail', 'Group of hikers on the rocky trail.'], |
| 66 | + ['DSC03201.JPG', 'boulders-with-grass', 'Cluster of boulders with native grass.'], |
| 67 | + ['DSC03216.JPG', 'gap-between-domes', 'Narrow gap between two Kata Tjuta domes.'], |
| 68 | + ['DSC03230.JPG', 'valley-floor-view', 'View from the Valley of the Winds toward the plain.'], |
| 69 | + ['DSC03242.JPG', 'stone-path', 'Stone path winding between the domes.'], |
| 70 | + ['DSC03246.JPG', 'tour-bus-at-kata-tjuta', 'Tour bus with Kata Tjuta in the background.'], |
| 71 | + ['DSC03251.JPG', 'kata-tjuta-evening', 'The full Kata Tjuta formation at evening.'], |
| 72 | + ['DSC03260.JPG', 'twin-domes', 'Two adjacent domes, close view.'], |
| 73 | + ], |
| 74 | + }, |
| 75 | + { |
| 76 | + folder: 'kings_canyon_outback', path: 'australia/outback/kings-canyon', |
| 77 | + title: 'Kings Canyon', subtitle: 'Watarrka National Park.', |
| 78 | + location: 'Watarrka National Park, NT, Australia', |
| 79 | + desc: 'Photos from the Kings Canyon rim walk in Watarrka National Park.', |
| 80 | + keywords: ['Kings Canyon', 'Watarrka', 'Outback', 'Northern Territory'], |
| 81 | + photos: [ |
| 82 | + ['DSC03426.JPG', 'balanced-boulder', 'A large boulder balanced on the trailhead at dawn.'], |
| 83 | + ['DSC03454.JPG', 'striated-canyon-wall', 'Detail of the layered canyon walls.'], |
| 84 | + ['DSC03460.JPG', 'first-dome', 'Misty first beehive dome on the rim walk.'], |
| 85 | + ['DSC03463.JPG', 'overhang', 'Looking up at a cliff overhang.'], |
| 86 | + ['DSC03467.JPG', 'misty-plateau', 'Eroded plateau under low mist.'], |
| 87 | + ['DSC03472.JPG', 'plateau-gum-tree', 'A gum tree growing on the canyon plateau.'], |
| 88 | + ['DSC03477.JPG', 'eroded-rim', 'Eroded canyon rim in the rain.'], |
| 89 | + ['DSC03483.JPG', 'mushroom-rock', 'Mushroom-shaped eroded rock formation.'], |
| 90 | + ['DSC03488.JPG', 'lost-city-domes', "The 'Lost City' of beehive domes."], |
| 91 | + ['DSC03491.JPG', 'person-on-rim', 'A figure standing on the canyon rim.'], |
| 92 | + ['DSC03492.JPG', 'cliff-detail', 'Close-up of the canyon cliff.'], |
| 93 | + ['DSC03496.JPG', 'shelby-on-rim', 'Shelby on the rim of Kings Canyon.'], |
| 94 | + ['DSC03507.JPG', 'garden-of-eden-hikers', 'Hikers near the Garden of Eden.'], |
| 95 | + ['DSC03526.JPG', 'garden-of-eden-bridge', 'Wooden bridge in the Garden of Eden.'], |
| 96 | + ['DSC03538.JPG', 'garden-of-eden-pool', 'Pool in the Garden of Eden.'], |
| 97 | + ['DSC03556.JPG', 'lost-city-overview', 'Wide view over the Lost City domes.'], |
| 98 | + ['DSC03563.JPG', 'trail-back-down', 'Trail leading back down off the rim.'], |
| 99 | + ], |
| 100 | + }, |
| 101 | + { |
| 102 | + folder: 'misc_outback', path: 'australia/outback/misc', |
| 103 | + title: 'Outback (misc)', subtitle: 'Red dirt, long roads, and roadside stops.', |
| 104 | + location: 'Red Centre, Australia', |
| 105 | + desc: 'Loose photos from the Australian Outback — landscapes, roads, and stops between the headline parks.', |
| 106 | + keywords: ['Outback', 'Red Centre', 'Australia road trip', 'Northern Territory'], |
| 107 | + photos: [ |
| 108 | + ['DSC03096.JPG', 'roadside-from-bus', 'Outback roadside seen through a bus window at dawn.'], |
| 109 | + ['DSC03109.JPG', 'emu-at-roadhouse', 'An emu in a pen at a roadhouse.'], |
| 110 | + ['DSC03111.JPG', 'kulgera-dawn', 'Roadside sign in early dawn light.'], |
| 111 | + ['DSC03115.JPG', 'outback-petrol-station', 'An empty outback petrol station at dawn.'], |
| 112 | + ['DSC03116.JPG', 'northern-territory-sign', 'Welcome to the Northern Territory sign.'], |
| 113 | + ['DSC03129.JPG', 'bus-fueling', 'Adventure Tours bus at a fuel stop.'], |
| 114 | + ['DSC03130.JPG', 'erldunda-sign', "Erldunda 'centre of the centre' roadhouse sign."], |
| 115 | + ['DSC03131.JPG', 'outback-junction', 'Quiet outback road junction.'], |
| 116 | + ['DSC03133.JPG', 'uluru-turnoff', 'The turnoff sign for Uluru.'], |
| 117 | + ['DSC03134.JPG', 'erldunda-motel-sign', 'Roadside signs for the Erldunda Motel.'], |
| 118 | + ['DSC03139.JPG', 'memorial-bell', 'Cast-iron memorial bell at a rest stop.'], |
| 119 | + ['DSC03140.JPG', 'cockatiel', 'A cockatiel perched in a wire cage.'], |
| 120 | + ['DSC03142.JPG', 'red-dirt-campground', 'Red dirt campground with native trees.'], |
| 121 | + ['DSC03143.JPG', 'desert-camp', 'Desert camp buildings from above.'], |
| 122 | + ['DSC03144.JPG', 'road-from-hilltop', 'Outback road seen from a hilltop.'], |
| 123 | + ['DSC03154.JPG', 'desert-oaks', 'Desert oaks dotting the landscape.'], |
| 124 | + ['DSC03156.JPG', 'glamping-tents-overview', 'Glamping tents at a desert camp.'], |
| 125 | + ['DSC03157.JPG', 'camp-from-above', 'Camp from above with tents and trees.'], |
| 126 | + ['DSC03160.JPG', 'glamping-tents', 'Two glamping tents in the red dirt.'], |
| 127 | + ], |
| 128 | + }, |
| 129 | + { |
| 130 | + folder: 'mona', path: 'australia/tasmania/mona', |
| 131 | + title: 'MONA', subtitle: 'Museum of Old and New Art.', |
| 132 | + location: 'Berriedale, Hobart, Tasmania, Australia', |
| 133 | + desc: 'Photos from MONA — the Museum of Old and New Art in Hobart, Tasmania.', |
| 134 | + keywords: ['MONA', 'Museum of Old and New Art', 'Hobart', 'Tasmania', 'contemporary art'], |
| 135 | + photos: [ |
| 136 | + ['DSC03605.JPG', 'cement-truck', "Wim Delvoye's intricately patterned cement truck sculpture."], |
| 137 | + ['DSC03612.JPG', 'the-butterfly-text', "Illuminated 'The Butterfly' text piece in a dark room."], |
| 138 | + ['DSC03621.JPG', 'cloaca-vessels', 'Glass vessels of the Cloaca digestion machine.'], |
| 139 | + ['DSC03623.JPG', 'cloaca-mechanism-detail', 'Inner mechanics of Cloaca — tubes and glass.'], |
| 140 | + ['DSC03633.JPG', 'god-is-your-enemy', "'God Is Your Enemy' text artwork."], |
| 141 | + ['DSC03634.JPG', 'noodles-on-pedestal', 'Instant noodles arranged as art on a pedestal.'], |
| 142 | + ['DSC03636.JPG', 'drinking-piss-text', 'A red text piece by an artist commenting on artists.'], |
| 143 | + ['DSC03637.JPG', 'so-much-for-optimism', "'So much for my fucking optimism' text piece."], |
| 144 | + ['DSC03639.JPG', 'fat-car', "Erwin Wurm's bulging red 'Fat Car' sculpture."], |
| 145 | + ['DSC03648.JPG', 'bit-fall', 'Looking up at the bit.fall water-text installation.'], |
| 146 | + ['DSC03649.JPG', 'cloaca-glass-dish', 'Glass dish suspended inside the Cloaca mechanism.'], |
| 147 | + ['DSC03651.JPG', 'cloaca-tubes', 'Backside of Cloaca tubes and pumps.'], |
| 148 | + ['DSC03656.JPG', 'cloaca-output', 'Output of Cloaca on a glass dish.'], |
| 149 | + ['DSC03660.JPG', 'white-temple', 'Carved white timber temple installation.'], |
| 150 | + ['DSC03667.JPG', 'photo-wall', 'Wall of framed photographs.'], |
| 151 | + ['DSC03671.JPG', 'erosion-texture', 'Textural close-up resembling stalactites.'], |
| 152 | + ['DSC03677.JPG', 'bonsai-vitrine', 'A bonsai tree in a glass vitrine.'], |
| 153 | + ['DSC03681.JPG', 'siloam-tunnel', 'Visitors viewing a tapestry through a concrete tunnel.'], |
| 154 | + ['DSC03684.JPG', 'hanging-films', 'Hanging strips of burnt film.'], |
| 155 | + ['DSC03687.JPG', 'snake-installation', 'Inside the snake installation.'], |
| 156 | + ['DSC03694.JPG', 'snake-overview', 'Wide view of the snake installation.'], |
| 157 | + ['DSC03706.JPG', 'glass-ceiling-detail', 'Detail of a glass-and-light ceiling installation.'], |
| 158 | + ['DSC03710.JPG', 'screens-corridor', 'Long corridor with rows of small video screens.'], |
| 159 | + ], |
| 160 | + }, |
| 161 | +]; |
| 162 | + |
| 163 | +for (const g of GALLERIES) { |
| 164 | + const stagingDir = path.join(ROOT, 'staging', g.folder); |
| 165 | + console.log(`\n=== ${g.title} → /media/${g.path} ===`); |
| 166 | + |
| 167 | + const images = []; |
| 168 | + let galleryDate = null; |
| 169 | + for (let n = 0; n < g.photos.length; n++) { |
| 170 | + const [srcName, slug, alt] = g.photos[n]; |
| 171 | + const srcAbs = path.join(stagingDir, srcName); |
| 172 | + try { await fs.access(srcAbs); } |
| 173 | + catch { console.log(` [${n+1}/${g.photos.length}] ${srcName} — missing, skip`); continue; } |
| 174 | + |
| 175 | + const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; |
| 176 | + const full = path.join(os.tmpdir(), `bulk-${id}-full.jpg`); |
| 177 | + const med = path.join(os.tmpdir(), `bulk-${id}-med.jpg`); |
| 178 | + const thumb = path.join(os.tmpdir(), `bulk-${id}-thumb.jpg`); |
| 179 | + |
| 180 | + await sharp(srcAbs).rotate().jpeg({ quality: 92, progressive: true, mozjpeg: true }).toFile(full); |
| 181 | + await sharp(srcAbs).rotate().resize({ width: 1400, withoutEnlargement: true }).jpeg({ quality: 82, progressive: true, mozjpeg: true }).toFile(med); |
| 182 | + await sharp(srcAbs).rotate().resize({ width: 600, withoutEnlargement: true }).jpeg({ quality: 78, progressive: true, mozjpeg: true }).toFile(thumb); |
| 183 | + |
| 184 | + const bytes = (await fs.stat(full)).size; |
| 185 | + const fullMeta = await sharp(full).metadata(); |
| 186 | + const ex = await exifr.parse(srcAbs, { exif: true, tiff: true }); |
| 187 | + |
| 188 | + const key = `${g.path}/${slug}`; |
| 189 | + process.stdout.write(` [${n+1}/${g.photos.length}] ${srcName} → ${slug} (${(bytes/1024/1024).toFixed(1)}MB)…`); |
| 190 | + const s3 = s3up(full, `${key}.jpg`); |
| 191 | + const s3_med = s3up(med, `${key}__med.jpg`); |
| 192 | + const s3_thumb = s3up(thumb, `${key}__thumb.jpg`); |
| 193 | + console.log(' ✓'); |
| 194 | + |
| 195 | + await fs.unlink(full); await fs.unlink(med); await fs.unlink(thumb); |
| 196 | + await fs.unlink(srcAbs); |
| 197 | + |
| 198 | + const exif = {}; |
| 199 | + if (ex?.Make || ex?.Model) exif.camera = [ex.Make, ex.Model].filter(Boolean).join(' ').trim(); |
| 200 | + if (ex?.LensModel) exif.lens = ex.LensModel; |
| 201 | + if (ex?.ISO) exif.iso = ex.ISO; |
| 202 | + if (ex?.FNumber) exif.aperture = 'f/' + ex.FNumber; |
| 203 | + if (ex?.ExposureTime) exif.shutter = formatShutter(ex.ExposureTime); |
| 204 | + if (ex?.FocalLength) exif.focal_length = Math.round(ex.FocalLength) + 'mm'; |
| 205 | + if (fullMeta?.width) exif.width = fullMeta.width; |
| 206 | + if (fullMeta?.height) exif.height = fullMeta.height; |
| 207 | + |
| 208 | + const captureDate = ex?.DateTimeOriginal ? ex.DateTimeOriginal.toISOString().slice(0, 10) : null; |
| 209 | + if (!galleryDate && captureDate) galleryDate = captureDate; |
| 210 | + |
| 211 | + images.push({ |
| 212 | + file: `${slug}.jpg`, |
| 213 | + alt, |
| 214 | + ...(captureDate ? { date: captureDate } : {}), |
| 215 | + s3, s3_med, s3_thumb, bytes, |
| 216 | + exif, |
| 217 | + }); |
| 218 | + } |
| 219 | + |
| 220 | + try { await fs.rmdir(stagingDir); } catch {} |
| 221 | + |
| 222 | + const galleryDir = path.join(ROOT, 'content/media', g.path); |
| 223 | + await fs.mkdir(galleryDir, { recursive: true }); |
| 224 | + const meta = { |
| 225 | + title: g.title, subtitle: g.subtitle, location: g.location, |
| 226 | + ...(galleryDate ? { date: galleryDate } : {}), |
| 227 | + seo: { description: g.desc, keywords: g.keywords }, |
| 228 | + images, |
| 229 | + }; |
| 230 | + await fs.writeFile(path.join(galleryDir, 'metadata.yaml'), yaml.dump(meta, { lineWidth: 100, noRefs: true, quotingType: '"' })); |
| 231 | + console.log(` → wrote ${galleryDir}/metadata.yaml`); |
| 232 | +} |
| 233 | + |
| 234 | +console.log('\nALL DONE.'); |
0 commit comments