The Photo Gallery Nobody Asked For (But Everyone Needs)
I wanted a simple photo gallery for my website. Upload photos from my camera, they appear on the site. How hard could it be?
Three days and 67 optimization attempts later, I had learned more about image processing than I ever wanted to know. Here’s what actually works.
The Problem
Modern cameras output massive files:
- Sony ARW (RAW): 24 MB per photo
- iPhone HEIC: 2-4 MB
- Standard JPEG: 3-5 MB
Loading 31 of these on a webpage? That’s hundreds of megabytes. Unacceptable.
The solution everyone recommends: “Just use WebP and responsive images!”
Cool. How?
Requirements
- Upload any format - ARW, JPEG, PNG, HEIC from camera or phone
- Automatic optimization - Run one script, generate all sizes
- Responsive images - Right size for mobile/tablet/desktop
- Preserve aesthetics - Dark moody photos stay dark
- Performance - Lazy loading, minimal JavaScript
Let’s build it.
Architecture Overview
public/photos/├── 1.ARW # Original camera RAW├── 2.jpeg # Straight from camera├── 3.heic # iPhone photo├── photos-metadata.json # Captions, locations└── _optimized/ # Generated files (git ignored) ├── 1-400w.webp # Mobile size ├── 1-800w.webp # Tablet size ├── 1-1200w.webp # Desktop size ├── 1-full.webp # Lightbox view └── ... (4 files per photo)Workflow:
- Drop photos in
public/photos/ - Run
npm run photos:optimize - Photos automatically appear on site
No manual resizing. No cloud services. Just one command.
The Optimization Script
First Attempt: Sharp (Failed)
Everyone recommends Sharp for Node.js image processing:
import sharp from 'sharp';
// Doesn't work for RAW files!await sharp('photo.ARW') .resize(800) .webp({ quality: 85 }) .toFile('photo-800w.webp');Problem: Sharp uses libvips, which requires complex compilation for RAW support. Even with libvips installed, ARW files failed consistently.
Sharp is excellent for JPEG/PNG but painful for camera RAW files. Don’t fight it.
Second Attempt: ImageMagick (Success!)
ImageMagick handles every format out of the box:
# Install ImageMagickbrew install imagemagick # macOSsudo apt-get install imagemagick # UbuntuThe Script
#!/usr/bin/env node
import { exec } from 'child_process';import { promisify } from 'util';import fs from 'fs/promises';import path from 'path';
const execAsync = promisify(exec);
const PHOTOS_DIR = './public/photos';const OPTIMIZED_DIR = './public/photos/_optimized';
// All supported formatsconst SUPPORTED_FORMATS = [ '.arw', '.cr2', '.nef', '.dng', // Camera RAW '.jpeg', '.jpg', '.png', '.heic', // Standard];
// Responsive sizesconst SIZES = [ { name: '400w', width: 400, quality: 88 }, { name: '800w', width: 800, quality: 90 }, { name: '1200w', width: 1200, quality: 92 }, { name: 'full', width: 2400, quality: 95 },];
async function getPhotoFiles() { const files = await fs.readdir(PHOTOS_DIR);
const photos = files .filter(file => { const ext = path.extname(file).toLowerCase(); return SUPPORTED_FORMATS.includes(ext); }) .map(file => ({ path: path.join(PHOTOS_DIR, file), name: path.basename(file, path.extname(file)), ext: path.extname(file).toLowerCase(), number: parseInt(file.match(/\d+/)?.[0] || '0'), }));
// Prefer JPEG over RAW (more on this later!) const photoMap = new Map(); const preferredFormats = ['.jpeg', '.jpg', '.png'];
for (const photo of photos) { const existing = photoMap.get(photo.number);
if (!existing) { photoMap.set(photo.number, photo); } else { const newIsPreferred = preferredFormats.includes(photo.ext); const existingIsPreferred = preferredFormats.includes(existing.ext);
if (newIsPreferred && !existingIsPreferred) { photoMap.set(photo.number, photo); } } }
return Array.from(photoMap.values());}
async function optimizePhoto(photo) { console.log(`Processing: ${path.basename(photo.path)}`);
await fs.mkdir(OPTIMIZED_DIR, { recursive: true });
for (const size of SIZES) { const outputPath = path.join( OPTIMIZED_DIR, `${photo.number}-${size.name}.webp` );
// ImageMagick command const command = [ 'magick', `"${photo.path}[0]"`, // [0] = first frame (for RAW/animated formats) '-auto-orient', // Respect EXIF rotation `-resize ${size.width}x`, // Maintain aspect ratio '-define webp:method=6', // Highest quality encoding '-define webp:alpha-quality=100', `-quality ${size.quality}`, `"${outputPath}"` ].join(' ');
await execAsync(command);
const stats = await fs.stat(outputPath); const sizeKB = (stats.size / 1024).toFixed(1); console.log(` ✓ ${size.name}: ${sizeKB} KB`); }}
async function main() { const photos = await getPhotoFiles(); console.log(`Found ${photos.length} photos\n`);
for (const photo of photos) { await optimizePhoto(photo); }
console.log('\n✓ Optimization complete!');}
main().catch(console.error);Running the Script
{ "scripts": { "photos:optimize": "node scripts/optimize-photos.js" }}npm run photos:optimizeAdd _optimized/ to .gitignore. Don’t commit generated files—regenerate on deploy.
The Dark Photo Problem
Everything worked great until I noticed: Photo #18 looked washed out.
Original ARW file: Moody, dark, dramatic. Optimized WebP: Bright, flat, lifeless.
What Went Wrong?
ImageMagick auto-adjusts exposure when converting RAW files. It assumes you want “properly exposed” photos.
But I wanted it dark! That was the aesthetic.
Failed Solutions
Attempt 1: Force color space
magick photo.ARW -colorspace sRGB -resize 800x output.webp# Still too brightAttempt 2: Reduce gamma
magick photo.ARW -gamma 0.8 -resize 800x output.webp# Better, but not rightAttempt 3: Manual adjustment flags
magick photo.ARW -auto-level -normalize -resize 800x output.webp# Even worse!The Actual Solution
Don’t convert RAW files directly. Export them manually first.
Workflow for dark/moody photos:
- Open
18.ARWin Lightroom or Capture One - Edit to desired darkness/mood
- Export as
18.jpeg(high quality JPEG) - Keep both
18.ARWand18.jpegin photos folder - Run optimization script
The script now prefers JPEG over RAW when both exist:
// Prefer JPEG over RAW when both existconst preferredFormats = ['.jpeg', '.jpg', '.png'];
for (const photo of photos) { const existing = photoMap.get(photo.number);
if (!existing) { photoMap.set(photo.number, photo); } else { const newIsPreferred = preferredFormats.includes(photo.ext); const existingIsPreferred = preferredFormats.includes(existing.ext);
if (newIsPreferred && !existingIsPreferred) { photoMap.set(photo.number, photo); console.log(` ℹ Preferring ${photo.name}.jpeg over RAW`); } }}Now:
- Quick uploads: Use camera JPEG directly
- Dark/moody photos: Edit in Lightroom, export, script uses your edited version
This hybrid workflow gives you speed AND control. Best of both worlds.
Responsive Images in HTML
The Picture Element
<picture> <source srcset=" /photos/_optimized/1-400w.webp 400w, /photos/_optimized/1-800w.webp 800w, /photos/_optimized/1-1200w.webp 1200w, /photos/_optimized/1-full.webp 1600w " sizes=" (max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw " type="image/webp" /> <img src="/photos/_optimized/1-800w.webp" alt="Photo description" loading="lazy" class="w-full h-full object-cover" /></picture>How it works:
- Browser picks the right size based on screen width
sizesattribute tells browser how much space the image takes- Fallback to 800w for older browsers
loading="lazy"defers offscreen images
Astro Implementation
---// Load photo metadataconst metadataResponse = await fetch('/photos/photos-metadata.json');const metadata = await metadataResponse.json();
// Get all photosconst photoFiles = Array.from({ length: 31 }, (_, i) => `${i + 1}.jpeg`);
const photos = photoFiles.map((filename, index) => { const baseNum = index + 1; const meta = metadata[filename] || {};
return { number: baseNum, alt: meta.alt || `Photo ${baseNum}`, location: meta.location || null, date: meta.date || null, sizes: { small: `/photos/_optimized/${baseNum}-400w.webp`, medium: `/photos/_optimized/${baseNum}-800w.webp`, large: `/photos/_optimized/${baseNum}-1200w.webp`, full: `/photos/_optimized/${baseNum}-full.webp`, } };}).reverse(); // Newest first---
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2"> {photos.map((photo) => ( <div class="aspect-square cursor-pointer"> <picture> <source srcset={`${photo.sizes.small} 400w, ${photo.sizes.medium} 800w, ${photo.sizes.large} 1200w, ${photo.sizes.full} 1600w`} sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw" type="image/webp" /> <img src={photo.sizes.medium} alt={photo.alt} loading="lazy" class="w-full h-full object-cover" /> </picture> </div> ))}</div>Metadata System
Keep captions separate from images:
{ "1.jpeg": { "alt": "Golden hour over the Pacific", "location": "Malibu, California", "date": "2025-01-10" }, "18.jpeg": { "alt": "Night street photography", "location": "Tokyo, Japan", "date": "2024-12-15" }}Why JSON instead of EXIF?
- EXIF is stripped during optimization (privacy!)
- JSON is easier to edit
- No need to parse binary data
- Can add custom fields
Performance Results
Before Optimization
- 31 original JPEGs: ~124 MB total
- Page load: 8-15 seconds on 4G
- Lighthouse score: 22/100
After Optimization
- 31 photos (all sizes): ~42 MB total
- Actual transferred (lazy load): ~2.8 MB initial
- Page load: 1.2 seconds on 4G
- Lighthouse score: 94/100
File size comparison:
| Format | Size | Savings |
|---|---|---|
| Original JPEG | 4.3 MB | - |
| 400w WebP | 32 KB | 99.3% |
| 800w WebP | 86 KB | 98% |
| 1200w WebP | 198 KB | 95.4% |
| Full WebP | 412 KB | 90.4% |
Mobile users downloading 32 KB instead of 4.3 MB. 134x smaller.
Advanced Features
Lightbox View
Simple fullscreen viewer:
function showLightbox(photoIndex) { const photo = photos[photoIndex]; const lightbox = document.getElementById('lightbox');
lightbox.innerHTML = ` <img src="${photo.sizes.full}" alt="${photo.alt}" /> <div class="caption">${photo.alt}</div> `;
lightbox.classList.remove('hidden'); document.body.style.overflow = 'hidden';}
// Keyboard navigationdocument.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideLightbox(); if (e.key === 'ArrowRight') showNext(); if (e.key === 'ArrowLeft') showPrev();});Bento Grid Layout
CSS columns for masonry effect:
.photo-grid { columns: 2; gap: 0.5rem;}
@media (min-width: 768px) { .photo-grid { columns: 3; }}
@media (min-width: 1024px) { .photo-grid { columns: 4; }}
.photo-card { break-inside: avoid; margin-bottom: 0.5rem;}Toggle between square grid and masonry:
function toggleView(mode) { const grid = document.getElementById('grid');
if (mode === 'bento') { grid.classList.remove('grid', 'grid-cols-4'); grid.classList.add('columns-4'); } else { grid.classList.remove('columns-4'); grid.classList.add('grid', 'grid-cols-4'); }}Lessons Learned
1. Don’t Fight RAW Auto-Exposure
ImageMagick will brighten dark RAW files. Accept it or export manually.
2. WebP Quality Sweet Spot
- 85: Visible artifacts
- 88-92: Perfect balance
- 95+: Diminishing returns
I use 88/90/92/95 for small/medium/large/full.
3. Lazy Loading is Essential
Don’t load 31 full-res images at once. Use loading="lazy".
4. srcset is Magic
Browser automatically picks the right size. Trust it.
5. Automate Everything
Manual resizing doesn’t scale. One script to rule them all.
Deployment
Build Process
name: Deploy
on: push: branches: [main]
jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3
- name: Install ImageMagick run: sudo apt-get install -y imagemagick
- name: Optimize photos run: npm run photos:optimize
- name: Build site run: npm run build
- name: Deploy run: npm run deployCDN Hosting
Serve optimized photos from CDN:
- Cloudflare Pages (free, fast)
- Vercel (good DX)
- Netlify (easy setup)
All handle WebP and caching automatically.
Conclusion
Building a photo gallery taught me:
- Sharp is great, except for RAW files
- ImageMagick handles everything, including RAW
- WebP saves 90%+ file size
- Responsive images are easier than you think
- Dark photos need manual editing
- Automation is non-negotiable
The final result:
- One command to optimize
- 90% smaller files
- Perfect responsiveness
- Dark photos stay dark
- Works with any camera format
Sometimes the simple solution is the best solution.
Total time investment: 3 days of fighting image libraries. Time saved on every upload: 15 minutes of manual work. Break-even point: ~20 uploads.
Worth it.
Full code available in the scripts/optimize-photos.js file of this site’s repo.