feat(gallery): responsive grid thumbnails (#1095) (#1109)

* feat(gallery): responsive grid thumbnails (#1095)

The half of #1095 that #1099 deliberately left out. Grid tiles are ~175
CSS px at the mobile 2-column default — about 530 device px on a DPR-3
phone — so the 300px thumbnail is upscaled ~1.8x and faces visibly mush.

Backend mirrors the preview tiers exactly: ?w= on the gallery thumbnail
route, whitelisted to 300/600/900, cached by width in storage, never
written to photos.thumbnail_path, and keyed by photo id for every source
type — basenames are not unique across events and a tier is served from a
cache hit without re-reading the source, which is how the preview tiers
nearly leaked one gallery's photo into another. The tier is in the ETag,
or a client holding the 300px file gets a 304 for its 600px request.
Cleanup and regenerate invalidation are wired the same way.

generateThumbnail now takes width/height overrides; it keeps the
configured `fit`, because the grid renders with object-cover and tiers
that were framed differently would visibly jump as the viewport changes.

The srcset only advertises tiers the SOURCE can fill. Thumbnails are
generated withoutEnlargement, so a 400px original asked for 900 comes
back at 400 — advertising "900w" would have the browser pick that
candidate and upscale it, which is the reported softness made worse. That
exact trap is why this was held back from #1099; the photo's own
dimensions are now the guard, measured on the SHORT edge because
thumbnails are square and a 4000x600 panorama can still only fill a 600
tile. A source that clears only one tier gets no srcset at all rather
than a single pointless candidate.

Two things this surfaced, both worth knowing separately:

`npx tsc --noEmit` type-checks NOTHING in this project — the root
tsconfig is `files: []` with project references, so the real command is
`tsc -b`, which is what build:check runs. Under tsc -b the repo has 43
files with pre-existing type errors; this branch adds none, and the one
error in a file I touched (PeopleManagerModal:91) is on main already and
unrelated to the line I changed.

* fix(gallery): wire grid tiers into the component that actually renders

The srcSet landed in PhotoGrid.tsx, which nothing imports — GalleryView
renders PhotoGridWithLayouts, and every grid layout funnels its tile
through the shared PhotoCard. The frontend half of #1095 shipped nothing.

Moved to PhotoCard, and switched from srcSet to a single sized URL, the
same shape PhotoLightbox already uses for preview tiers. AuthenticatedImage
fetches its src with the gallery bearer token and renders the blob; an
<img> carrying a w-descriptor srcSet ignores src entirely, so that fetch
would have been discarded and the browser would have issued its own —
unauthenticated, and resolved against the page origin rather than the
configured API host. One URL keeps the auth path and halves the requests.

The tier comes from the tile's measured width via the IntersectionObserver
entry, read on the same render that reveals the image so nothing is fetched
twice. Column counts differ per layout and shift again with thumbnailScale,
so the breakpoint table is only a fallback.

Also closes what the tier cache leaked or served stale:

- ensureThumbnailAtWidth short-circuits videos. Their thumbnail is a poster
  frame, so the tier path handed the video file to Sharp — after downloading
  it in full on S3, uncached, once per request.
- The ETag names the tier actually served, not the one requested. A fallback
  to the canonical thumbnail was caching a 300px image under a 900px key.
- Tier height scales from the configured aspect ratio instead of forcing a
  square; with fit:'cover' a 300x200 canonical and a 600x600 tier are two
  different crops and the photo reframed between tiers.
- The canonical short-circuit compares against the configured thumbnail_width,
  not the 300 default, so a 600px install stops generating duplicate tiers.
- Tier invalidation on /admin/thumbnails/regenerate, above the local-file
  check that skips S3 and external rows.
- Tier cleanup in replacePhoto and deleteEventCascade. Both derive keys from
  the photo row, so the rows have to be read before they change or vanish.
  Preview tiers had the same two holes and are swept alongside.

The clamp no longer drops a tier when the source falls between them: a 400px
short edge asked for 600 returns all 400 pixels, where clamping to 300 threw
100 of them away.

Backend 18 tier tests, frontend 22. Full suites green: 293 backend across the
touched areas, 185 frontend, build clean, no new type errors.

* fix(gallery): measure the tile, and stop regenerating the w300 tier

Follow-up to the review of #1095. Closes the three items left open there,
plus a defect the previous commit introduced.

**The w300 tier regenerated on every request.** Decoupling the canonical
short-circuit from the hardcoded 300 left generateThumbnail still tagging
against DEFAULT_THUMBNAIL_WIDTH. On an install with thumbnail_width=600 a
w=300 request wrote `thumb_<name>` while the caller probed for
`thumb_w300_<name>`: the cache never hit, so every request re-downloaded the
original and ran Sharp, and the file it left behind was in no cleanup list.
The tag now follows the configured width, and thumbnailTierKeys lists all
three widths — which one is canonical is a setting, so excluding 300 stranded
exactly the file a 600-configured install generates.

**The tier is chosen from the tile's measured width.** The observer entry
only exists for `lazy` cards, and Mosaic, Masonry and Timeline don't pass it
— Mosaic is 1-up on mobile where Grid is 2-up, so they are the layouts a
breakpoint guess gets most wrong. Measured in a layout effect and gated: the
image is not rendered until the width is known, so AuthenticatedImage never
mounts with a src it has to replace. Attaching the observer ref
unconditionally instead refetches every tile, since React flushes passive
effects before the sync re-render a layout effect triggers — removing the
gate makes the new single-request test fail, which is how that was confirmed
rather than assumed.

**Gallery Premium has its own card** and never reached the shared one, so its
tiles kept pulling the canonical thumbnail. MasonryPhotoAlbum already hands
the laid-out width to the render prop, so it needed no measurement.

**Event rename orphaned tiers.** The key embeds the basename, so the DB
update is the point past which the old keys cannot be derived. Dropped inside
the filename-changed branch, not the loop body: unconditional would fire four
storage deletes per photo on every rename, 20k calls against S3 for a
5,000-photo event that merely had its slug adjusted. Preview tiers had the
same hole and are swept alongside.

Carousel is the seventh layout and deliberately gets no tiering: its
filmstrip thumbs are 80 CSS px, under the canonical 300 even at DPR 3.

Tests: first PhotoCard suite (6), backend tier suite 21. Both new behaviours
mutation-checked — reverting the width tag, the render gate, the measurement,
or the rename sweep each fails a test. Full suites green: 298 backend across
the touched areas, 191 frontend, build clean, no new type or lint findings.

* fix(gallery): mount masonry cards once, into a measured layout

Found while capturing screenshots for this PR, by attributing every thumbnail
request to a photo id rather than eyeballing the grid.

Masonry columns mode starts at 3 columns and runs its greedy distribution off
a hardcoded 300px estimate until the container has been measured. Cards
mounted into that guess are torn down when it settles — photos move to a
different parent column, so React unmounts them — and since #1095 each mount
picks its tier from its own width, the two mounts request two DIFFERENT urls.

Measured on a 1440px desktop, production build, 62 photos:

  before   45 photos fetched at canonical AND w600, 17 stuck on w600
           107 requests
  after    62 photos, canonical only, 62 requests

Mobile was already landing on one tier either way, so both mounts produced the
same url and the second was a cache hit — which is why it looked clean and the
desktop case did not.

The fix is the gate the rows/justified mode in this same file already applies
for the same reason (line 346): hold the cards back until containerWidth is
known. Only columns mode was missing it. Grid and Justified take their column
counts from CSS breakpoints, so they have no transient measured value to
discard and are unaffected.

Worth noting this was NOT visible on main: without tiering both mounts request
the same url, so the browser cache absorbs the duplicate. Tiering is what turns
a harmless remount into a second download — the regression is this PR's, which
is why it is fixed here rather than deferred.

Frontend suite 194 passed (3 new). Mutation-checked: removing the gate fails
the mount-once and placeholder tests.

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-21 19:26:21 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 00776234fd
commit 887bdbe6e5
16 changed files with 926 additions and 22 deletions
+23
View File
@@ -224,6 +224,16 @@ async function deleteEventCascade(eventId, adminContext) {
throw err;
}
// Responsive tiers (#1095 / #492) live in the top-level thumbnails/ and
// previews/ directories, not under the event folder the filesystem sweep
// below removes, and their keys are derived from the photo rows — which the
// transaction is about to delete. So they are read here, while the rows
// still exist, and swept after the commit; miss that window and every tier
// this event generated is orphaned with nothing left to derive its key from.
const tieredPhotos = await db('photos')
.where('event_id', eventId)
.select('id', 'path', 'filename', 'source_origin', 'external_relpath');
await db.transaction(async (trx) => {
// 1. Delete activity logs (audit trail)
await trx('activity_logs').where('event_id', eventId).del();
@@ -292,6 +302,19 @@ async function deleteEventCascade(eventId, adminContext) {
}
});
// Tier sweep, post-commit and best-effort for the same reason as the folder
// removal above: an orphaned derivative is recoverable noise, a rolled-back
// delete is not.
try {
const { deleteThumbnailTiers, deletePreviewTiers } = require('../../services/imageProcessor');
for (const photo of tieredPhotos) {
await deleteThumbnailTiers(photo);
await deletePreviewTiers(photo);
}
} catch (tierErr) {
logger.warn('Failed to delete responsive tiers during cascade delete', { eventId, error: tierErr.message });
}
// Audit trail (outside the transaction so a logging failure can't undo
// the actual delete).
await logActivity('event_deleted',
+2
View File
@@ -682,6 +682,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
// independently, on demand — so keying their cleanup off preview_path
// would strand exactly the photos that were only ever viewed on a phone.
await require('../services/imageProcessor').deletePreviewTiers(photo);
await require('../services/imageProcessor').deleteThumbnailTiers(photo);
// Delete pre-generated watermark if exists
if (photo.watermark_path) {
@@ -846,6 +847,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
// Outside the guard: a tier can exist when the canonical rendition never
// did, so keying cleanup off preview_path would strand phone-only photos.
await require('../services/imageProcessor').deletePreviewTiers(photo);
await require('../services/imageProcessor').deleteThumbnailTiers(photo);
if (photo.watermark_path) {
await watermarkGeneratorService.deleteForPhoto(photo.id);
}
+21 -3
View File
@@ -125,7 +125,11 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
try {
const { eventId } = req.body; // Optional: regenerate for specific event only
let query = db('photos').select('id', 'event_id', 'path');
// source_origin/external_relpath/filename are selected for
// deleteThumbnailTiers below — it derives the tier keys from the same
// fields ensureThumbnailAtWidth used to write them.
let query = db('photos')
.select('id', 'event_id', 'path', 'source_origin', 'external_relpath', 'filename');
if (eventId) {
query = query.where('event_id', eventId);
}
@@ -151,7 +155,21 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
try {
const storagePath = getStoragePath();
const originalPath = path.join(storagePath, 'events/active', photo.path);
// Drop the responsive tiers first (#1095), same as the preview
// endpoint below. They are cached by width outside thumbnail_path
// and their key carries no settings version, so regenerating only
// the canonical rendition leaves phones served the old fit, quality
// or format indefinitely — which is exactly what this endpoint is
// invoked to undo after a settings change.
//
// Above the fs.access below, not after it: that check only passes
// for managed photos on a local filesystem. On S3, and for external
// or reference rows, it fails and skips the photo — so invalidating
// after it would leave stale tiers on precisely the deployments
// where they are hardest to notice.
await require('../services/imageProcessor').deleteThumbnailTiers(photo);
// Check if original file exists
try {
await fs.access(originalPath);
@@ -160,7 +178,7 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
errorCount++;
continue;
}
// Regenerate thumbnail
const thumbnailPath = await generateThumbnail(originalPath, { regenerate: true });
+24 -2
View File
@@ -2251,7 +2251,26 @@ router.get('/:slug/thumbnail/:photoId',
}
// Ensure thumbnail exists and is valid, regenerate if needed
const thumbnailPath = await ensureThumbnail(photo);
// Responsive tier (#1095), whitelisted the same way the preview route's
// is. Unrecognised or absent falls through to the canonical 300px
// thumbnail, so existing clients are untouched.
const { THUMBNAIL_WIDTHS, normalizeTierWidth, ensureThumbnailAtWidth } =
require('../services/imageProcessor');
const thumbTier = normalizeTierWidth(req.query.w, THUMBNAIL_WIDTHS);
const thumbnailPath = thumbTier
? (await ensureThumbnailAtWidth(photo, thumbTier)) || (await ensureThumbnail(photo))
: await ensureThumbnail(photo);
// What was actually resolved, not what was asked for. A tier request can
// land on the canonical thumbnail — generation failed, or the row is a
// video — and stamping the requested tier into the ETag below would then
// have the client cache a 300px image under its 900px key for the full
// max-age, with no way to notice.
const servedTier = thumbTier && thumbnailPath
&& path.basename(thumbnailPath).startsWith(`thumb_w${thumbTier}_`)
? thumbTier
: null;
if (!thumbnailPath) {
logger.error(`Failed to generate thumbnail for photo ${photoId}`);
@@ -2285,7 +2304,10 @@ router.get('/:slug/thumbnail/:photoId',
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
const etag = `"thumb-${photoId}-${mtimeMs}${watermarkHash}"`;
// Tier in the ETag, same reason as the preview route: without it a
// client holding the 300px thumbnail gets a 304 for its 600px request
// and renders the small one, which is this feature inverted.
const etag = `"thumb-${photoId}-${servedTier || 'def'}-${mtimeMs}${watermarkHash}"`;
// Check if client has valid cached version
if (req.headers['if-none-match'] === etag) {
+1
View File
@@ -222,6 +222,7 @@ async function archiveEvent(event) {
// Outside the guard: a tier can exist when the canonical rendition never
// did, so keying cleanup off preview_path would strand phone-only photos.
await require('./imageProcessor').deletePreviewTiers(photo);
await require('./imageProcessor').deleteThumbnailTiers(photo);
// Best effort: remove watermarked variants too if a refactor added them.
if (photo.watermark_path) {
await storage.delete(photo.watermark_path).catch(() => {});
@@ -181,6 +181,20 @@ class EventRenameService {
logger.warn('Could not rename photo file', { oldFilename, error: error.message });
}
}
// Responsive tiers (#1095 / #492) are keyed off the basename, so the
// update below is the point past which the old keys can no longer be
// derived — a later delete or archive would compute the new ones and
// leave these in storage forever. Dropped rather than renamed: they
// are a pure cache and the next request regenerates.
//
// Inside this branch, not the loop body: only a filename change moves
// the key. Sweeping unconditionally would fire four storage deletes
// per photo on every rename, which is 20k calls against S3 for a
// 5,000-photo event that merely had its slug adjusted.
const imageProcessor = require('./imageProcessor');
await imageProcessor.deleteThumbnailTiers(photo);
await imageProcessor.deletePreviewTiers(photo);
}
// Update database record
+135 -3
View File
@@ -212,13 +212,24 @@ const contentTypeFor = (format) => {
async function generateThumbnail(imagePath, options = {}) {
const sourceBasename = path.basename(imagePath);
const outputBasename = options.outputBasename || sourceBasename;
const thumbnailFilename = `thumb_${outputBasename}`;
const thumbnailRelKey = path.posix.join('thumbnails', thumbnailFilename);
const storage = getStorage();
// Get thumbnail settings
const settings = await getThumbnailSettings();
// Tag against the CONFIGURED canonical width, not the 300 default — the tag
// has to agree with the key ensureThumbnailAtWidth probed for. On an install
// with thumbnail_width=600 a w=300 request used to write `thumb_<name>` while
// the caller looked for `thumb_w300_<name>`: the cache never hit, so every
// single request re-downloaded the original and ran Sharp, and the file it
// left behind was in no cleanup list.
const canonicalWidth = settings.width || DEFAULT_THUMBNAIL_WIDTH;
const widthTag = options.width && options.width !== canonicalWidth
? `w${options.width}_`
: '';
const thumbnailFilename = `thumb_${widthTag}${outputBasename}`;
const thumbnailRelKey = path.posix.join('thumbnails', thumbnailFilename);
// Force regeneration: drop the existing object before writing the new one
if (options.regenerate) {
await storage.delete(thumbnailRelKey).catch(() => {});
@@ -241,7 +252,12 @@ async function generateThumbnail(imagePath, options = {}) {
// Strip EXIF/metadata from thumbnails (privacy: prevent GPS leak etc.)
sharpInstance = sharpInstance.withMetadata(false);
sharpInstance = sharpInstance.resize(settings.width, settings.height, {
// options.width/height override the admin setting for responsive tiers
// (#1095). The configured `fit` is kept deliberately: the grid renders
// with object-cover, so every tier must be cropped the same way or the
// browser would swap between differently-framed images as the viewport
// changes.
sharpInstance = sharpInstance.resize(options.width || settings.width, options.height || settings.height, {
withoutEnlargement: true,
fit: settings.fit,
position: 'center'
@@ -731,6 +747,119 @@ async function deletePreviewTiers(photo) {
await Promise.all(previewTierKeys(photo).map((k) => storage.delete(k).catch(() => {})));
}
/**
* Thumbnail storage keys for every responsive tier of a photo (#1095).
* Mirrors previewTierKeys see there for why they are derived rather than
* tracked.
*
* Every width is listed, the canonical one included, and deliberately: which
* width is canonical depends on the thumbnail_width setting, so on a
* 600-configured install it is w300 that exists as a tier file. Reading the
* setting here would make the whole cleanup path async for no gain deleting
* a key that was never written is already a swallowed no-op, so the inclusive
* list is both simpler and the one that cannot strand a file.
*/
function thumbnailTierKeys(photo) {
if (!photo) return [];
const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
const sourceBasename = path.basename(
(isExternal ? (photo.external_relpath || photo.filename) : photo.path) || `photo-${photo.id}`
);
const outputBasename = `p${photo.id}_${sourceBasename}`;
return THUMBNAIL_WIDTHS
.map((w) => path.posix.join('thumbnails', `thumb_w${w}_${outputBasename}`));
}
async function deleteThumbnailTiers(photo) {
const storage = getStorage();
await Promise.all(thumbnailTierKeys(photo).map((k) => storage.delete(k).catch(() => {})));
}
/**
* A thumbnail at a specific tier width (#1095).
*
* Same contract as ensurePreviewImageAtWidth: pure cache, keyed by width,
* never written to photos.thumbnail_path. The key is scoped by photo id for
* every source type basenames are not unique across events, and a tier is
* served from a cache hit without re-reading the source, so an unscoped key
* would hand one gallery's photo to another.
*/
async function ensureThumbnailAtWidth(photo, width) {
if (!width) return ensureThumbnail(photo);
// Against the CONFIGURED canonical width, not the 300 default. An install
// that set thumbnail_width to 600 already has a 600px thumbnail; generating
// a w600 tier for it would download the original and run Sharp to produce a
// byte-equivalent duplicate, once per photo.
const settings = await getThumbnailSettings();
const canonicalWidth = settings.width || DEFAULT_THUMBNAIL_WIDTH;
if (width === canonicalWidth) return ensureThumbnail(photo);
// Videos never take the tier path. Their thumbnail is a poster frame from
// videoProcessor, not a resize of the stored file, so the code below would
// hand the video itself to Sharp — after withLocalCopy has downloaded the
// whole thing on an S3 backend. Nothing caches that failure, so a crawler
// walking ?w= over a gallery of videos repeats the download every request.
if (photo.media_type === 'video' || String(photo.mime_type || '').startsWith('video/')) {
return ensureThumbnail(photo);
}
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
const storage = getStorage();
let event;
try {
event = await db('events').where('id', photo.event_id).first();
} catch (e) {
return null;
}
if (!event) return null;
const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
const sourceBasename = path.basename(
(isExternal ? (photo.external_relpath || photo.filename) : photo.path) || `photo-${photo.id}`
);
const outputBasename = `p${photo.id}_${sourceBasename}`;
const key = path.posix.join('thumbnails', `thumb_w${width}_${outputBasename}`);
try {
if (await storage.stat(key)) return key;
} catch (e) {
// regenerate below
}
// Scale the height from the configured aspect ratio rather than forcing a
// square. Thumbnails are square on a default install, but the settings API
// accepts any width/height in 50..1000 — and with fit:'cover' a 300x200
// canonical next to a 600x600 tier are two different crops, so the photo
// would visibly reframe as the tile size changes.
const height = Math.round(width * (settings.height / canonicalWidth));
try {
if (isExternal) {
const localPath = resolvePhotoFilePath(event, photo);
return await generateThumbnail(localPath, {
regenerate: true, outputBasename, width, height,
});
}
const sourceKey = resolvePhotoStorageKey(event, photo);
if (!sourceKey) return null;
return await withLocalCopy(sourceKey, async (localPath) => {
const proc = await withProcessableImage(localPath, sourceKey);
try {
return await generateThumbnail(proc.path, {
regenerate: true, outputBasename, width, height,
});
} finally {
proc.cleanup();
}
});
} catch (e) {
logger.warn(`Thumbnail tier w${width} failed for photo ${photo.id}: ${e.message}`);
return null;
}
}
async function ensurePreviewImageAtWidth(photo, width) {
if (!width || width === DEFAULT_PREVIEW_LONG_EDGE) return ensurePreviewImage(photo);
@@ -981,6 +1110,9 @@ async function resizeToBox(inputBuffer, box, options = {}) {
module.exports = {
ensurePreviewImageAtWidth,
ensureThumbnailAtWidth,
thumbnailTierKeys,
deleteThumbnailTiers,
previewTierKeys,
deletePreviewTiers,
PREVIEW_WIDTHS,
@@ -10,7 +10,10 @@ const path = require('path');
const fsp = require('fs/promises');
const sharp = require('sharp');
const { db } = require('../database/db');
const { generateThumbnail, extractCaptureDate, withProcessableImage } = require('./imageProcessor');
const {
generateThumbnail, extractCaptureDate, withProcessableImage,
deleteThumbnailTiers, deletePreviewTiers,
} = require('./imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const watermarkGeneratorService = require('./watermarkGeneratorService');
const { getStorage } = require('./storage');
@@ -98,6 +101,12 @@ async function replacePhoto(existingPhoto, newFileTempPath, { originalFilename,
if (existingPhoto.thumbnail_path && existingPhoto.thumbnail_path !== thumbnailPath) {
await storage.delete(existingPhoto.thumbnail_path).catch(() => {});
}
// Responsive tiers, keyed off the OLD row (#1095 / #492). Their key embeds
// the basename, which the update below replaces — so this is the last
// moment they can be derived at all. Miss it and a later delete or archive
// computes keys from the new basename and leaves them in storage forever.
await deleteThumbnailTiers(existingPhoto);
await deletePreviewTiers(existingPhoto);
try {
await watermarkGeneratorService.deleteForPhoto(existingPhoto.id);
} catch {