feat: photography features & image optimizations

- Hero carousel: gallery.yaml hero accepts single UUID or array, crossfade component
- Full-bleed single albums: subpages with 1 album skip grid, render photos directly
- EXIF on hover: camera/lens/focal length overlay on grid items (toggle via exifOnHover)
- Customizable grid: columns/gap/aspectRatio/layout (masonry|uniform) via gallery.yaml
- Per-subpage grid overrides: each subpage can override global grid config
- True masonry: per-image aspect ratios from EXIF dimensions
- next/image migration: homepage hero, album covers, grid thumbnails, about portrait
- ThumbHash blur placeholders and dominant color backgrounds
- Custom next/image loader via image proxy with width/quality params
- Lightbox fade-in transition with dominant color background
- Password-protected subpages, health check, rate limiting, OG images
This commit is contained in:
ralksta
2026-02-14 10:30:27 +01:00
parent 3c69860a64
commit b7217c4a76
31 changed files with 1998 additions and 162 deletions

42
lib/thumbhash.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* ThumbHash utilities for generating blur placeholders and dominant colors.
* Uses the thumbhash package to decode Immich's base64-encoded ThumbHash strings
* into data URLs and hex color values.
*/
import { thumbHashToRGBA, thumbHashToDataURL } from 'thumbhash';
/**
* Convert an Immich base64-encoded ThumbHash into a tiny data URL
* suitable for next/image's blurDataURL prop.
*/
export function thumbHashToBlurDataUrl(base64: string): string {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return thumbHashToDataURL(bytes);
}
/**
* Extract the dominant/average color from a ThumbHash as a hex string.
* Computes the weighted average of all non-transparent pixels.
*/
export function thumbHashToDominantHex(base64: string): string {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
const { w, h, rgba } = thumbHashToRGBA(bytes);
let r = 0, g = 0, b = 0, total = 0;
for (let i = 0; i < w * h; i++) {
const a = rgba[i * 4 + 3];
if (a > 0) {
const weight = a / 255;
r += rgba[i * 4] * weight;
g += rgba[i * 4 + 1] * weight;
b += rgba[i * 4 + 2] * weight;
total += weight;
}
}
if (total === 0) return '#888888';
const avg = (v: number) => Math.round(v / total).toString(16).padStart(2, '0');
return `#${avg(r)}${avg(g)}${avg(b)}`;
}