Choose Theme

The Photo Gallery Nobody Asked For (But Everyone Needs)

· 10 min read · #Performance #Web Development #Images
--

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

  1. Upload any format - ARW, JPEG, PNG, HEIC from camera or phone
  2. Automatic optimization - Run one script, generate all sizes
  3. Responsive images - Right size for mobile/tablet/desktop
  4. Preserve aesthetics - Dark moody photos stay dark
  5. 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:

  1. Drop photos in public/photos/
  2. Run npm run photos:optimize
  3. 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:

Terminal window
# Install ImageMagick
brew install imagemagick # macOS
sudo apt-get install imagemagick # Ubuntu

The Script

scripts/optimize-photos.js
#!/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 formats
const SUPPORTED_FORMATS = [
'.arw', '.cr2', '.nef', '.dng', // Camera RAW
'.jpeg', '.jpg', '.png', '.heic', // Standard
];
// Responsive sizes
const 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

package.json
{
"scripts": {
"photos:optimize": "node scripts/optimize-photos.js"
}
}
Terminal window
npm run photos:optimize
💡
Tip

Add _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

Terminal window
magick photo.ARW -colorspace sRGB -resize 800x output.webp
# Still too bright

Attempt 2: Reduce gamma

Terminal window
magick photo.ARW -gamma 0.8 -resize 800x output.webp
# Better, but not right

Attempt 3: Manual adjustment flags

Terminal window
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:

  1. Open 18.ARW in Lightroom or Capture One
  2. Edit to desired darkness/mood
  3. Export as 18.jpeg (high quality JPEG)
  4. Keep both 18.ARW and 18.jpeg in photos folder
  5. Run optimization script

The script now prefers JPEG over RAW when both exist:

// Prefer JPEG over RAW when both exist
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);
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
ℹ️
Note

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
  • sizes attribute tells browser how much space the image takes
  • Fallback to 800w for older browsers
  • loading="lazy" defers offscreen images

Astro Implementation

src/pages/photos/index.astro
---
// Load photo metadata
const metadataResponse = await fetch('/photos/photos-metadata.json');
const metadata = await metadataResponse.json();
// Get all photos
const 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:

public/photos/photos-metadata.json
{
"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:

FormatSizeSavings
Original JPEG4.3 MB-
400w WebP32 KB99.3%
800w WebP86 KB98%
1200w WebP198 KB95.4%
Full WebP412 KB90.4%

Mobile users downloading 32 KB instead of 4.3 MB. 134x smaller.

Advanced Features

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 navigation
document.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

.github/workflows/deploy.yml
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 deploy

CDN 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.

💡
Tip

Full code available in the scripts/optimize-photos.js file of this site’s repo.

Related