fix(gallery): stop the lightbox loading originals to display a photo (#1166) (#1169)

* fix(gallery): stop the lightbox loading originals to display a photo (#1166)

The lightbox read `preview_url`, which the server only emits once an admin has
flipped lightbox_preview_enabled — off by default. So a stock install fell
straight through to `url`, the untouched original: a reporter measured 16.5 MB
for a photo whose preview is 345 KB. The lightbox renders its neighbours too,
so opening one photo pulled three originals.

`slideshow_url` is the same /preview/:id URL, watermark query included, and
has been emitted unconditionally for images since #1015 — the slideshow never
had a fallback worth taking. Preferring it fixes every existing install with no
migration and no admin action, and `url` still backstops videos, where both
derivative URLs are null.

Verified on the local rig with the toggle off, so the photos API returns
preview_url: null exactly as filed. Opening one photo:

  before   GET /photo/82, /photo/81, /photo/21      (3 originals)
  after    GET /preview/82?w=1280, /preview/81, /preview/21

397 KB -> 23 KB per image on that gallery's test photos.

The toggle no longer decides whether the lightbox uses previews, so its copy
said something untrue; it now describes what it still does, which is
pre-generate rather than wait for the first guest to open a photo. Updated in
en/de/fr/sl, the locales that carry those keys.

* fix(gallery): cover the layouts the lightbox fix missed (#1166)

External review found the fix was incomplete, and the review of it found one
more.

Premium galleries were untouched. PhotoGridWithLayouts returns early for
gallery-premium, which builds its own yet-another-react-lightbox slides with
`src: photo.url` — so those galleries kept pulling full originals and the
reported bandwidth problem remained. They now use lightboxImageUrl for the
display source; `download` deliberately stays on photo.url, because what a
guest saves must be the original.

The Story layout was worse, and neither the issue nor the review caught it:
StoryPhotoCard rendered the full original as its GRID TILE, at object-cover in
a small card. That is the one place where "hundreds of megabytes for a gallery"
was literally true. It now uses the per-device thumbnail tier like PhotoCard,
and its PhotoSwipe source uses the preview tier.

Animated GIFs keep the original. generatePreviewImage always encodes JPEG, so
routing an animated source through the preview tier would have replaced the
animation with its first frame — a regression the toggle-off default never
had. Animated WebP has the same problem and cannot be distinguished by MIME
alone; that needs the backend to report it (Sharp's `metadata.pages > 1`) and
is left rather than costing every static-WebP gallery the bandwidth fix.

The settings copy claimed too much. "Pre-generate lightbox previews" does not
generate anything on save — it unlocks the regenerate button and keeps
preview_url emitted. Reworded to say that, in en/de/fr/sl.

Not changed: the review's P1 said this bypassed the secure-image route on
enhanced/maximum galleries. It does not. AuthenticatedImage collects
requiresToken and secureUrlTemplate into an explicitly-voided unusedProps and
never substitutes {{token}}, so on those protection levels photo.url was a
literal `.../secure/82/{{token}}` that returns 400 — the lightbox was falling
back to the 300px thumbnail, not to a protected image. Verified against a live
maximum-protection gallery. Codex withdrew the finding on that evidence.

* fix(gallery): keep premium downloads working and story framing intact (#1166)

Second review round, three findings — two of them regressions this PR
introduced.

Premium Download became a no-op. handleDownloadFromLightbox recovered the
photo with `filteredPhotos.find(p => p.url === slide.src)`, and slide.src is a
derivative now, so the lookup found nothing and the button silently did
nothing. The slide carries the photo id and the handler resolves by that;
what Download hands over is still the original.

Story cards were reframed. thumbnail_fit is seeded to 'cover' on every
install, so thumbnails are square centre-crops — and story cards are not
square (400x500 in the carousel, fixed-height in the desktop grid), so the
card's own object-cover cropped them a second time and every photo shifted.
They now use the preview tier, which is fit:'inside' and therefore the whole
frame: the card looks exactly as it did before, without pulling an original.

APNG joins the animated-format guard. It declares image/apng and the preview
route would serve a static frame. Animated WebP still cannot be detected from
MIME and remains the documented gap.

* fix(gallery): keep PNG on the original, alpha and all (#1166)

Third review round.

generatePreviewImage encodes JPEG, which drops ALPHA as well as animation — a
transparent PNG came back flattened against a solid background. And an APNG is
normally reported as image/png, so the image/apng check alone missed the
common upload path. PNG now stays on the original: it is where transparency is
the norm, and rare enough in an event gallery that the bandwidth given up is
small.

Animated or alpha WebP still cannot be detected from MIME and remains the
documented gap; it needs the backend to report Sharp's `pages`/`hasAlpha`.

Two further findings are acknowledged and deferred rather than fixed here:

- Story cards now request /preview on mount, so a cold gallery generates its
  previews in one burst. That is a new CPU cost, not a regression — those cards
  previously fetched full ORIGINALS on mount, which is strictly worse. Doing it
  properly means viewport-gating AuthenticatedImage, which is a change to a
  component every gallery surface uses and belongs in its own PR.
- The premium layout memoizes slide URLs, so rotating the device before opening
  the lightbox can leave a photo on the tier chosen for the old geometry. The
  result is a slightly undersized image, and the fix is a resize subscription
  this PR does not otherwise need.

* fix(gallery): load Story images on approach, and give the hero its own tier (#1166)

Every card in a Story gallery mounts at page load — `whileInView` gates the
animation, not the render — and AuthenticatedImage fetches from an effect on
mount, so all of them requested at once. That was tolerable while they pointed
at photo.url, because nothing was generated; pointing them at the preview tier
meant a gallery with cold previews would Sharp-decode every original in one
burst. The image now waits until the card is within 200px of the viewport,
using framer-motion's useInView — the same observer the entrance animation
already relies on — with `once` so a card never unloads on scroll-away.

Verified on a 62-photo Story gallery: 3 images fetched at load, growing to 15
as you scroll, where all 62 would have fired before.

While confirming that, the hero turned out to be doing the same thing the
cards were. StoryHero rendered photo.url as a full-bleed object-cover
background — a full original on the critical path for first paint of every
Story gallery — when hero_url exists for exactly this and is a 1920x1080 cover
crop emitted unconditionally for every photo (gallery.js:1139).

That gallery now issues no /photo/ request at all: hero_url for the hero,
the preview tier for the cards, and only as they come into range.

* fix(gallery): make the Story hero fix actually work on external galleries (#1166)

External review of the stable twin, both applying here too.

hero_url was inert for external media. ensureHeroImage only ever called
resolvePhotoStorageKey, which returns null for external/reference photos by
design — and that null was handed straight to withLocalCopy, which throws, so
the hero route caught it and redirected to the full ORIGINAL. #1078 fixed
exactly this shape for ensurePreviewImage and nobody carried it across. It
stayed invisible until this PR pointed the Story hero at hero_url: on a
managed gallery that is a real saving, on a reference-mode gallery it quietly
changed nothing. ensureHeroImage now has the same external branch
ensurePreviewImage does — direct fs read, per-photo output basename — and
returns null instead of throwing for a reference-mode row with no
source_origin.

The format bypass trusted mime_type, which is not trustworthy here. Migration
039 backfilled every pre-existing photo to image/jpeg regardless of what it
was, and adminExternalMedia inserts rows with no mime_type at all — so a
mislabelled PNG sailed past the guard and came back flattened. It now checks
the filename extension as well.

* test(gallery): the hero fixture follows the root-relative relpath contract (#1166)

external_relpath has been resolved from EXTERNAL_MEDIA_ROOT rather than from
event.external_path since #1163 landed. This fixture still carried the
base-relative form — its own comment noted the change was 'a separate stack' —
so the two tests stopped resolving and ensureHeroImage returned null the moment
that stack merged. The production path was never affected.

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-26 08:46:29 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 05e23ef1a1
commit 77953c15c1
14 changed files with 495 additions and 42 deletions
@@ -0,0 +1,116 @@
/**
* ensureHeroImage must work for external/reference photos (#1166 follow-up).
*
* resolvePhotoStorageKey returns null for external photos by design, and that
* null used to be handed straight to withLocalCopy, which throws — so the hero
* route caught it and redirected to the full ORIGINAL. #1078 fixed exactly
* this shape for ensurePreviewImage and nobody carried it across.
*
* It only became visible when the Story hero started asking for hero_url
* instead of photo.url: on a managed gallery that is a real saving, on a
* reference-mode gallery it quietly changed nothing.
*/
const path = require('path');
const fs = require('fs').promises;
const os = require('os');
const sharp = require('sharp');
const EXTERNAL_ROOT = path.join(os.tmpdir(), `picpeak-hero-ext-${process.pid}`);
process.env.EXTERNAL_MEDIA_ROOT = EXTERNAL_ROOT;
jest.mock('../../src/database/db', () => {
const state = { event: null, updates: [] };
const api = (table) => {
if (table === 'events') return { where: () => ({ first: async () => state.event }) };
if (table === 'photos') {
return { where: (criteria) => ({ update: async (values) => { state.updates.push({ criteria, values }); return 1; } }) };
}
throw new Error(`unexpected table in test: ${table}`);
};
api.__state = state;
return { db: api };
});
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
const storageModule = require('../../src/services/storage');
const { db } = require('../../src/database/db');
const EVENT = { id: 7, slug: 'nas-wedding', source_mode: 'reference', external_path: 'weddings/2026-08' };
async function writeSourceJpeg(absPath, { width = 2400, height = 1600 } = {}) {
await fs.mkdir(path.dirname(absPath), { recursive: true });
const buf = Buffer.alloc(width * height * 3);
for (let i = 0; i < buf.length; i++) buf[i] = (i * 7) % 256;
await sharp(buf, { raw: { width, height, channels: 3 } }).jpeg({ quality: 90 }).toFile(absPath);
}
describe('ensureHeroImage — external sources', () => {
let storage; let storageRoot; let imageProcessor;
beforeAll(async () => {
storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-hero-store-'));
storage = new LocalFsStorage({ root: storageRoot });
await storage.init();
storageModule.setStorageForTesting(storage);
delete require.cache[require.resolve('../../src/services/imageProcessor')];
imageProcessor = require('../../src/services/imageProcessor');
await fs.mkdir(path.join(EXTERNAL_ROOT, EVENT.external_path), { recursive: true });
}, 30000);
afterAll(async () => {
storageModule.resetStorage();
await fs.rm(storageRoot, { recursive: true, force: true }).catch(() => {});
await fs.rm(EXTERNAL_ROOT, { recursive: true, force: true }).catch(() => {});
});
beforeEach(() => { db.__state.event = EVENT; db.__state.updates = []; });
it.each(['external', 'reference'])('generates a hero for a %s photo off the mount', async (sourceOrigin) => {
const name = `${sourceOrigin}-hero.jpg`;
await writeSourceJpeg(path.join(EXTERNAL_ROOT, EVENT.external_path, name));
const photo = {
id: sourceOrigin === 'external' ? 301 : 302,
event_id: EVENT.id,
source_origin: sourceOrigin,
// Root-relative, as stored since #1163: external_relpath is resolved
// from EXTERNAL_MEDIA_ROOT, not from event.external_path. The base-
// relative form this fixture used to carry stopped resolving the moment
// that landed, and ensureHeroImage returned null.
external_relpath: path.join(EVENT.external_path, name),
filename: name,
hero_path: null,
};
const key = await imageProcessor.ensureHeroImage(photo);
// The regression: this returned null and the route redirected to the
// full original.
expect(key).toBeTruthy();
expect(await storage.exists(key)).toBe(true);
// Per-photo basename, so two events sharing a NAS filename cannot clobber
// each other — same rule as the preview tier.
expect(key).toContain(`ext${photo.id}_`);
expect(db.__state.updates).toEqual([{ criteria: { id: photo.id }, values: { hero_path: key } }]);
});
it('returns null rather than throwing when the external source is gone', async () => {
const photo = {
id: 303, event_id: EVENT.id, source_origin: 'external',
external_relpath: path.join(EVENT.external_path, 'not-on-the-mount.jpg'), filename: 'not-on-the-mount.jpg', hero_path: null,
};
await expect(imageProcessor.ensureHeroImage(photo)).resolves.toBeNull();
expect(db.__state.updates).toEqual([]);
});
it('returns null for a reference-mode row with no source_origin', async () => {
// Mode falls back to the event's, so resolvePhotoStorageKey yields null.
// That used to reach withLocalCopy and throw out of the function.
const photo = {
id: 304, event_id: EVENT.id, source_origin: null, external_relpath: null,
filename: 'orphan.jpg', path: 'nas-wedding/individual/orphan.jpg', hero_path: null,
};
await expect(imageProcessor.ensureHeroImage(photo)).resolves.toBeNull();
});
});
+54 -8
View File
@@ -570,16 +570,13 @@ async function isHeroValid(heroPath) {
* Ensure a hero image exists for a photo, regenerate if needed
*/
async function ensureHeroImage(photo) {
const { resolvePhotoStorageKey } = require('./photoResolver');
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
let sourceKey;
let event;
try {
const event = await db('events').where('id', photo.event_id).first();
sourceKey = resolvePhotoStorageKey(event, photo);
logger.info(`Ensuring hero image for photo ${photo.id} from key: ${sourceKey}`);
event = await db('events').where('id', photo.event_id).first();
} catch (e) {
const msg = (e && e.message) ? e.message : String(e);
logger.error(`Failed to resolve original key for hero image (photo ${photo.id}): ${msg}`);
logger.error(`Failed to load event for hero image (photo ${photo.id}): ${e.message}`);
return null;
}
@@ -591,7 +588,56 @@ async function ensureHeroImage(photo) {
logger.warn(`Invalid hero image detected for photo ${photo.id}, regenerating...`);
}
const newHeroPath = await withLocalCopy(sourceKey, async (localPath) => {
// External sources never reach the managed backend, so resolvePhotoStorageKey
// returns null for them by design — and this function used to feed that null
// straight to withLocalCopy, which throws, so the hero route fell back to
// redirecting at the full original. #1078 fixed exactly this for
// ensurePreviewImage and nobody carried it across; it only became visible
// when the Story hero started asking for hero_url instead of the original
// (#1166), which on a reference-mode gallery quietly changed nothing.
const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
let newHeroPath;
if (isExternal) {
// Mirrors the external branch in ensurePreviewImage: a direct fs read off
// the mount, so no withLocalCopy, and a per-photo outputBasename so two
// events referencing the same NAS basename cannot clobber each other.
let localPath;
try {
localPath = resolvePhotoFilePath(event, photo);
} catch (e) {
logger.error(`Failed to resolve external file for hero image (photo ${photo.id}): ${e.message}`);
return null;
}
const sourceBasename = path.basename(photo.external_relpath || photo.filename || `photo-${photo.id}`);
newHeroPath = await generateHeroImage(localPath, {
regenerate: true,
outputBasename: `ext${photo.id}_${sourceBasename}`,
});
if (newHeroPath) {
await db('photos').where({ id: photo.id }).update({ hero_path: newHeroPath });
}
return newHeroPath;
}
let sourceKey;
try {
sourceKey = resolvePhotoStorageKey(event, photo);
logger.info(`Ensuring hero image for photo ${photo.id} from key: ${sourceKey}`);
} catch (e) {
const msg = (e && e.message) ? e.message : String(e);
logger.error(`Failed to resolve original key for hero image (photo ${photo.id}): ${msg}`);
return null;
}
if (!sourceKey) {
// Reference-mode event holding a row with no source_origin: the mode falls
// back to the event's and resolvePhotoStorageKey returns null. Honour the
// null-on-failure contract rather than feeding it to withLocalCopy.
logger.warn(`No managed storage key for hero image (photo ${photo.id}); skipping generation`);
return null;
}
newHeroPath = await withLocalCopy(sourceKey, async (localPath) => {
const proc = await withProcessableImage(localPath, sourceKey);
try {
return await generateHeroImage(proc.path, { regenerate: true, outputBasename: proc.outputBasename });
@@ -6,7 +6,7 @@ import type { Photo, GalleryPerson } from '../../types';
import { useSavePhotoToDevice } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common';
import { PhotoFeedback } from './PhotoFeedback';
import { previewUrlForViewport } from './imageTiers';
import { lightboxImageUrl } from './imageTiers';
import { feedbackService, type ColorLabel, type KeybindMode } from '../../services/feedback.service';
import { PhotoColorLabels } from './PhotoColorLabels';
import { resolveFeedbackKey, colorShortcutHints } from '../../utils/feedbackKeybinds';
@@ -1143,7 +1143,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
// original) when preview_url is null — happens
// when the toggle is off, when the photo is a
// video, or briefly while lazy generation runs.
src={previewUrlForViewport(photo.preview_url, photo) || photo.url}
src={lightboxImageUrl(photo)}
alt={photo.filename}
fallbackSrc={photo.thumbnail_url || undefined}
className="max-w-full max-h-full object-contain select-none pointer-events-none"
@@ -1168,7 +1168,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
<AuthenticatedImage
// Same preview-prefer-with-fallback logic as the
// off-screen tile above (#492).
src={previewUrlForViewport(photo.preview_url, photo) || photo.url}
src={lightboxImageUrl(photo)}
alt={photo.filename}
fallbackSrc={photo.thumbnail_url || undefined}
className="max-w-full max-h-full object-contain select-none"
@@ -0,0 +1,169 @@
/**
* The lightbox must not display a photo by downloading the original (#1166).
*
* `preview_url` is only emitted when the admin has flipped
* lightbox_preview_enabled, which is off by default so a stock install fell
* through to `url`. A reporter measured 16.5 MB for a photo whose preview is
* 345 KB, and the lightbox renders its neighbours too, so one open pulled
* three originals.
*
* `slideshow_url` is the same /preview/:id URL and has been emitted
* unconditionally for images since #1015. Preferring it is what fixes existing
* installs; the tests below pin that, and pin the two cases that must still
* reach `url`.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { lightboxImageUrl } from '../imageTiers';
const realWidth = window.innerWidth;
const realDpr = window.devicePixelRatio;
function setViewport(width: number, dpr: number, height = 844) {
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true });
Object.defineProperty(window, 'innerHeight', { value: height, configurable: true });
Object.defineProperty(window, 'devicePixelRatio', { value: dpr, configurable: true });
Object.defineProperty(navigator, 'connection', { value: undefined, configurable: true });
}
// Desktop, so the top tier is chosen and no ?w= is appended — keeps these
// assertions about URL SELECTION rather than tier maths (pinned separately in
// imageTiers.test.ts).
afterEach(() => setViewport(realWidth, realDpr));
const BIG = { width: 4160, height: 6240 };
describe('lightboxImageUrl (#1166)', () => {
it('uses the preview tier when the admin opted in', () => {
setViewport(1440, 2, 900);
expect(lightboxImageUrl({
url: '/api/gallery/g/photo/47',
preview_url: '/api/gallery/g/preview/47',
slideshow_url: '/api/gallery/g/preview/47',
...BIG,
})).toBe('/api/gallery/g/preview/47');
});
it('uses the preview tier when they did NOT — the reported install', () => {
// The regression, exactly as filed: preview_url null, slideshow_url set.
setViewport(1440, 2, 900);
expect(lightboxImageUrl({
url: '/api/gallery/g/photo/47',
preview_url: null,
slideshow_url: '/api/gallery/g/preview/47',
...BIG,
})).toBe('/api/gallery/g/preview/47');
});
it('never serves the original while a derivative exists', () => {
setViewport(1440, 2, 900);
for (const preview_url of [null, undefined, '', '/api/gallery/g/preview/47']) {
expect(lightboxImageUrl({
url: '/api/gallery/g/photo/47',
preview_url,
slideshow_url: '/api/gallery/g/preview/47',
...BIG,
})).not.toContain('/photo/');
}
});
it('falls back to the original when neither derivative exists', () => {
// Videos: the server emits null for both, and the player needs the real
// source.
expect(lightboxImageUrl({
url: '/api/gallery/g/photo/47',
preview_url: null,
slideshow_url: null,
})).toBe('/api/gallery/g/photo/47');
});
it('carries the watermark query through', () => {
// preview_url and slideshow_url are built from the same string server-side
// (gallery.js), so the wm parameter is on whichever one is used — losing it
// would serve an unwatermarked frame to a gallery that asked for one.
setViewport(1440, 2, 900);
expect(lightboxImageUrl({
url: '/api/gallery/g/photo/47?wm=3',
preview_url: null,
slideshow_url: '/api/gallery/g/preview/47?wm=3',
...BIG,
})).toBe('/api/gallery/g/preview/47?wm=3');
});
it.each(['image/gif', 'image/apng', 'image/png'])('keeps the original for %s, which the preview tier would flatten', (mime) => {
// generatePreviewImage always encodes JPEG, so routing an animated source
// through it replaces the animation with its first frame — behaviour the
// toggle-off default never had.
setViewport(1440, 2, 900);
expect(lightboxImageUrl({
url: '/api/gallery/g/photo/47',
preview_url: null,
slideshow_url: '/api/gallery/g/preview/47',
mime_type: mime,
...BIG,
})).toBe('/api/gallery/g/photo/47');
});
it('catches a PNG that migration 039 mislabelled as image/jpeg', () => {
// 039 backfilled every pre-existing photo's mime_type to image/jpeg, and
// the external-media importer inserts rows with none at all — so trusting
// MIME alone lets exactly the transparent photos through.
setViewport(1440, 2, 900);
expect(lightboxImageUrl({
url: '/api/gallery/g/photo/47',
preview_url: null,
slideshow_url: '/api/gallery/g/preview/47',
mime_type: 'image/jpeg',
filename: 'logo-with-alpha.png',
...BIG,
})).toBe('/api/gallery/g/photo/47');
});
it('catches one with no mime_type at all, as external imports write them', () => {
setViewport(1440, 2, 900);
expect(lightboxImageUrl({
url: '/api/gallery/g/photo/47',
preview_url: null,
slideshow_url: '/api/gallery/g/preview/47',
filename: 'animation.gif',
...BIG,
})).toBe('/api/gallery/g/photo/47');
});
it('still uses the preview tier for ordinary still formats', () => {
setViewport(1440, 2, 900);
for (const mime_type of ['image/jpeg', 'image/webp', undefined]) {
expect(lightboxImageUrl({
url: '/api/gallery/g/photo/47',
preview_url: null,
slideshow_url: '/api/gallery/g/preview/47',
mime_type,
...BIG,
})).toBe('/api/gallery/g/preview/47');
}
});
it('still sizes the fallback tier for the device', () => {
// The #1095 behaviour has to survive the new fallback: a phone must not
// pull the 1920 rendition just because the URL came from slideshow_url.
setViewport(390, 3);
// A tall portrait is bound by height, so a DPR-3 phone genuinely needs the
// top tier — which is served without ?w=, keeping those URLs byte-identical
// to the ones already in browser and CDN caches.
expect(lightboxImageUrl({
url: '/api/gallery/g/photo/47',
preview_url: null,
slideshow_url: '/api/gallery/g/preview/47',
...BIG,
})).toBe('/api/gallery/g/preview/47');
// Landscape on the same phone needs less, and says so.
expect(lightboxImageUrl({
url: '/api/gallery/g/photo/47',
preview_url: null,
slideshow_url: '/api/gallery/g/preview/47',
width: 3000,
height: 2000,
})).toBe('/api/gallery/g/preview/47?w=1280');
});
});
@@ -107,6 +107,66 @@ export function previewUrlForViewport(
return withWidth(previewUrl, width);
}
/**
* What the lightbox actually puts in an <img> (#1166).
*
* `preview_url` is only emitted when the admin has flipped
* lightbox_preview_enabled, which is off by default so a stock install fell
* straight through to `url`, the untouched original. A reporter measured
* 16.5 MB per photo where the preview is 345 KB, and the lightbox renders its
* neighbours too, so opening one photo pulled three originals.
*
* `slideshow_url` is the same /preview/:id URL, watermark query and all, but
* emitted unconditionally for images since #1015 the slideshow has never had
* a fallback worth taking. Preferring it here fixes every existing install
* without an admin touching a setting.
*
* `url` stays as the last resort, which is where videos land (both derivative
* URLs are null for them) and where an image goes if the server ever stops
* emitting either. The preview route generates lazily and redirects to the
* original on any failure, so nothing here can show less than it does today.
*/
export function lightboxImageUrl(photo: {
url: string;
preview_url?: string | null;
slideshow_url?: string | null;
mime_type?: string;
filename?: string;
original_filename?: string | null;
width?: number | null;
height?: number | null;
}): string {
// Animated formats keep the original. generatePreviewImage always encodes
// JPEG (imageProcessor.js:668), so routing an animated source through the
// preview tier would replace the animation with its first frame — a
// regression the toggle-off default never had.
//
// PNG goes with them, for two reasons that share a cause: generatePreviewImage
// also drops ALPHA (a transparent source flattens against a solid
// background), and an APNG is normally reported as image/png rather than
// image/apng, so the specific check alone would miss the common upload path.
// PNG is where transparency is the norm, and it is rare enough in an event
// gallery that the bandwidth given up is small.
//
// MIME is all the frontend has. Animated or alpha WebP reports image/webp
// exactly like an ordinary still, and cannot be told apart here — fixing
// that needs the backend to report it (Sharp's `metadata.pages`, and
// `hasAlpha`) rather than costing every static-WebP gallery the bandwidth
// fix on a guess. That is the known remaining gap.
// Checked against the FILENAME as well as the MIME, because mime_type is not
// trustworthy here: migration 039 backfilled every pre-existing photo as
// image/jpeg regardless of what it was, and the external-media importer
// inserts rows without a mime_type at all. A mislabelled PNG would otherwise
// sail past this and come back flattened.
const ORIGINAL_ONLY = ['image/gif', 'image/apng', 'image/png'];
const ORIGINAL_ONLY_EXT = /\.(gif|apng|png)$/i;
const name = photo.original_filename || photo.filename || '';
if ((photo.mime_type && ORIGINAL_ONLY.includes(photo.mime_type)) || ORIGINAL_ONLY_EXT.test(name)) {
return photo.url;
}
return previewUrlForViewport(photo.preview_url || photo.slideshow_url, photo) || photo.url;
}
/**
* An aspect-preserved rendition for a face avatar (#1096).
*
@@ -31,6 +31,7 @@ import { useDownloadPhoto } from '../../../hooks/useGallery';
import { toast } from 'react-toastify';
import './GalleryPremiumLayout.css';
import { lightboxImageUrl } from '../imageTiers';
interface PhotoCardProps {
photo: Photo;
@@ -318,7 +319,17 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
// when the admin has flipped the original-filenames toggle (#508).
const slides = useMemo(() => {
return filteredPhotos.map(photo => ({
src: photo.url,
// Display source, not the original (#1166). This layout returns early
// from PhotoGridWithLayouts and never renders PhotoLightbox, so it needs
// its own call — without it a premium gallery keeps pulling
// multi-megabyte originals to show a photo on screen. `download` below
// deliberately stays on photo.url: what a guest saves must be the full
// original.
src: lightboxImageUrl(photo),
// The download handler used to recover the photo by matching slide.src
// against photo.url. src is a derivative now, so that lookup would find
// nothing and Download would silently do nothing (#1166 review).
photoId: photo.id,
alt: photo.filename,
width: photo.width || 1200,
height: photo.height || 800,
@@ -438,10 +449,15 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
}
}, [selectedPhotos, slug, t, downloadChoices, onPickResolution]);
const handleDownloadFromLightbox = useCallback((slide: { src?: string }) => {
const handleDownloadFromLightbox = useCallback((slide: { src?: string; photoId?: number }) => {
if (!allowDownloads || !slide.src) return;
const photo = filteredPhotos.find(p => p.url === slide.src);
// By id, carried on the slide. Matching on src broke the moment the slide
// stopped being the original — and what Download hands over must stay the
// original regardless of what is rendered.
const photo = slide.photoId != null
? filteredPhotos.find(p => p.id === slide.photoId)
: filteredPhotos.find(p => p.url === slide.src);
if (photo) {
analyticsService.trackDownload(photo.id, slug, false);
downloadPhotoMutation.mutate({
@@ -39,9 +39,14 @@ export const StoryHero: React.FC<StoryHeroProps> = ({
<div className="story-hero">
{/* Background */}
<div className="story-hero-bg">
{photo && (photo.url || photo.thumbnail_url) ? (
{photo && (photo.hero_url || photo.url || photo.thumbnail_url) ? (
<AuthenticatedImage
src={photo.url || photo.thumbnail_url || ''}
// hero_url, which is what it is for (#1166): a 1920x1080 cover crop,
// and this is a full-bleed object-cover background. It rendered
// photo.url — a full original on the critical path for first paint
// of every Story gallery. Emitted unconditionally for every photo
// (gallery.js:1139), so the fallbacks below are belt-and-braces.
src={photo.hero_url || photo.url || photo.thumbnail_url || ''}
alt="Hero"
className="w-full h-full object-cover"
isGallery={true}
@@ -1,9 +1,10 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import React, { useRef, useState } from 'react';
import { motion, useInView } from 'framer-motion';
import { Heart } from 'lucide-react';
import { AuthenticatedImage } from '../../../common';
import { ColorLabelBadge } from '../../ColorLabelBadge';
import type { Photo } from '../../../../types';
import { lightboxImageUrl } from '../../imageTiers';
interface StoryPhotoCardProps {
photo: Photo;
@@ -38,8 +39,24 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
void _galleryId;
const [isLoaded, setIsLoaded] = useState(false);
// Don't fetch until the card is near the viewport (#1166).
//
// Every card in a Story gallery mounts at page load — `whileInView` gates the
// ANIMATION, not the render — and AuthenticatedImage fetches from an effect
// on mount, so all of them requested at once. That was tolerable while they
// pointed at `photo.url`, because nothing was generated; pointing them at
// the preview tier means a gallery with cold previews would Sharp-decode
// every original in one burst.
//
// `once` so a card that has loaded never unloads on scroll-away, and the
// same 200px margin the entrance animation uses so the image is already in
// flight by the time the card animates in.
const cardRef = useRef<HTMLDivElement>(null);
const isNearViewport = useInView(cardRef, { once: true, margin: '200px' });
return (
<motion.div
ref={cardRef}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-50px' }}
@@ -48,7 +65,10 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
>
<a
href={photo.url}
data-pswp-src={photo.url}
// PhotoSwipe's full-size source (#1166). The preview tier, like every
// other lightbox surface — the original is what Download hands out,
// not what gets rendered on screen.
data-pswp-src={lightboxImageUrl(photo)}
data-pswp-width={photo.width || 1200}
data-pswp-height={photo.height || 800}
data-photo-id={photo.id}
@@ -60,8 +80,24 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
}}
className="block w-full h-full"
>
{/* The placeholder keeps the card's box while the image is still
out of range, so nothing reflows when it arrives. */}
{!isNearViewport ? (
<div className="w-full h-full bg-neutral-200 dark:bg-neutral-800" aria-hidden="true" />
) : (
<AuthenticatedImage
src={photo.url}
// A card tile, and it used to render the full ORIGINAL at
// object-cover — the one place where the reporter's "hundreds of
// megabytes for a gallery" was literally true (#1166).
//
// The preview tier rather than the thumbnail, deliberately.
// thumbnail_fit is seeded to 'cover' on every install, so thumbnails
// are square centre-crops; these cards are not square (400x500 in
// the carousel, fixed-height in the desktop grid), so a thumbnail
// would be cropped a second time by object-cover and reframe every
// photo. Previews use fit:'inside' and are the whole frame, so the
// card looks exactly as it did while no longer pulling an original.
src={lightboxImageUrl(photo)}
alt={photo.filename}
onLoad={() => setIsLoaded(true)}
className={`w-full h-full object-cover transition-all duration-700 ease-out will-change-transform ${
@@ -77,6 +113,7 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
)}
</a>
{/* Colour label (#1044) — same badge every layout uses. */}
@@ -288,18 +288,19 @@ export const ThumbnailsTab: React.FC = () => {
</Button>
</Card>
{/* Lightbox preview tier (#492). Independent opt-in from the
thumbnail size/quality settings above costs disk but
dramatically speeds up lightbox open on mobile / slow
connections by serving an aspect-preserved ~1920px JPEG
instead of the multi-megabyte original. */}
{/* Lightbox preview tier (#492). The toggle no longer decides whether
the lightbox uses previews since #1166 it always does, falling back
to slideshow_url, which the server emits for every image. Saving it
does not itself generate anything, so the copy does not claim to:
what it does is unlock the regenerate button below and keep
preview_url emitted. */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
<Image className="w-5 h-5 text-primary-600" />
{t('settings.thumbnails.lightboxTitle', 'Lightbox Preview Tier')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.thumbnails.lightboxHelp', 'When enabled, the lightbox loads an aspect-preserved ~1920px JPEG (typically 200500 KB) instead of the full original (often 512 MB). Originals are still served when guests click Download. Costs roughly one extra preview file per photo on disk; previews are generated lazily on first open and stored in /previews.')}
{t('settings.thumbnails.lightboxHelp', 'The lightbox shows an aspect-preserved ~1920px JPEG (typically 200500 KB) rather than the full original (often 512 MB). Originals are still served when guests click Download. Previews cost roughly one extra file per photo on disk and are stored in /previews.')}
</p>
<label className="flex items-start gap-3 cursor-pointer mb-4">
@@ -311,10 +312,10 @@ export const ThumbnailsTab: React.FC = () => {
/>
<span className="text-sm">
<span className="font-medium text-neutral-900 dark:text-neutral-100">
{t('settings.thumbnails.lightboxToggle', 'Use medium-resolution previews in the lightbox')}
{t('settings.thumbnails.lightboxToggle', 'Enable eager preview generation')}
</span>
<span className="block text-xs text-neutral-600 dark:text-neutral-400 mt-0.5">
{t('settings.thumbnails.lightboxToggleHelp', 'Off by default. Flip on after deciding the perceived-perf win is worth the extra disk usage.')}
{t('settings.thumbnails.lightboxToggleHelp', 'Off by default: each preview is built the first time a guest opens that photo. Turning this on unlocks the button below, which builds them all up front.')}
</span>
</span>
</label>
+3 -3
View File
@@ -1746,9 +1746,9 @@
"regenerateStarted": "Neugenerierung der Vorschaubilder gestartet",
"regenerateError": "Neugenerierung der Vorschaubilder konnte nicht gestartet werden",
"lightboxTitle": "Lightbox-Vorschau-Stufe",
"lightboxHelp": "Wenn aktiviert, lädt die Lightbox ein seitenverhältnis-erhaltendes JPEG mit ~1920 px (typischerweise 200500 KB) statt des vollen Originals (oft 512 MB). Beim Download durch Gäste wird weiterhin das Original ausgeliefert. Kostet pro Foto eine zusätzliche Vorschaudatei auf der Festplatte; Vorschauen werden beim ersten Öffnen erzeugt und unter /previews gespeichert.",
"lightboxToggle": "Mittelauflösende Vorschauen in der Lightbox verwenden",
"lightboxToggleHelp": "Standardmäßig deaktiviert. Aktivieren, sobald der gefühlte Geschwindigkeitsgewinn den zusätzlichen Speicherbedarf rechtfertigt.",
"lightboxHelp": "Die Lightbox zeigt ein seitenverhältnis-erhaltendes JPEG mit ~1920 px (typischerweise 200500 KB) statt des vollen Originals (oft 512 MB). Beim Download durch Gäste wird weiterhin das Original ausgeliefert. Vorschauen kosten pro Foto etwa eine zusätzliche Datei auf der Festplatte und liegen unter /previews.",
"lightboxToggle": "Vorab-Erzeugung von Vorschauen aktivieren",
"lightboxToggleHelp": "Standardmäßig deaktiviert: Jede Vorschau entsteht, sobald ein Gast das Foto zum ersten Mal öffnet. Aktivieren schaltet die Schaltfläche unten frei, die alle im Voraus erzeugt.",
"regeneratePreviewsButton": "Alle Vorschauen neu generieren",
"previewsRegenerateStarted": "Neugenerierung der Lightbox-Vorschauen gestartet",
"previewsRegenerateError": "Neugenerierung der Vorschauen konnte nicht gestartet werden",
+3 -3
View File
@@ -1354,9 +1354,9 @@
"regenerateStarted": "Thumbnail regeneration started",
"regenerateError": "Failed to start thumbnail regeneration",
"lightboxTitle": "Lightbox Preview Tier",
"lightboxHelp": "When enabled, the lightbox loads an aspect-preserved ~1920px JPEG (typically 200500 KB) instead of the full original (often 512 MB). Originals are still served when guests click Download. Costs roughly one extra preview file per photo on disk; previews are generated lazily on first open and stored in /previews.",
"lightboxToggle": "Use medium-resolution previews in the lightbox",
"lightboxToggleHelp": "Off by default. Flip on after deciding the perceived-perf win is worth the extra disk usage.",
"lightboxHelp": "The lightbox shows an aspect-preserved ~1920px JPEG (typically 200500 KB) rather than the full original (often 512 MB). Originals are still served when guests click Download. Previews cost roughly one extra file per photo on disk and are stored in /previews.",
"lightboxToggle": "Enable eager preview generation",
"lightboxToggleHelp": "Off by default: each preview is built the first time a guest opens that photo. Turning this on unlocks the button below, which builds them all up front.",
"regeneratePreviewsButton": "Regenerate All Previews",
"previewsRegenerateStarted": "Lightbox preview regeneration started",
"previewsRegenerateError": "Failed to start preview regeneration",
+3 -3
View File
@@ -983,9 +983,9 @@
"regenerateStarted": "Régénération des miniatures démarrée",
"regenerateError": "Échec du démarrage de la régénération des miniatures",
"lightboxTitle": "Niveau d'aperçu de la visionneuse",
"lightboxHelp": "Lorsqu'il est activé, la visionneuse charge un JPEG de ~1920px préservant les proportions (généralement 200500 Ko) au lieu de l'original complet (souvent 512 Mo). Les originaux sont toujours servis lorsque les invités cliquent sur Télécharger. Coûte environ un fichier d'aperçu supplémentaire par photo sur le disque ; les aperçus sont générés paresseusement lors de la première ouverture et stockés dans /previews.",
"lightboxToggle": "Utiliser des aperçus de résolution moyenne dans la visionneuse",
"lightboxToggleHelp": "Désactivé par défaut. Activez après avoir décidé que le gain de performance perçu vaut l'utilisation supplémentaire de disque.",
"lightboxHelp": "La visionneuse affiche un JPEG d'environ 1920 px préservant les proportions (généralement 200500 Ko) plutôt que l'original complet (souvent 512 Mo). Les originaux sont toujours servis lorsque les invités cliquent sur Télécharger. Les aperçus coûtent environ un fichier supplémentaire par photo sur le disque et sont stockés dans /previews.",
"lightboxToggle": "Activer la génération anticipée des aperçus",
"lightboxToggleHelp": "Désactivé par défaut : chaque aperçu est créé la première fois qu'un invité ouvre la photo. L'activer débloque le bouton ci-dessous, qui les crée tous à l'avance.",
"regeneratePreviewsButton": "Régénérer tous les aperçus",
"previewsRegenerateStarted": "Régénération des aperçus de la visionneuse démarrée",
"previewsRegenerateError": "Échec du démarrage de la régénération des aperçus",
+3 -3
View File
@@ -983,9 +983,9 @@
"regenerateStarted": "Ponovno ustvarjanje sličic se je začelo",
"regenerateError": "Ponovnega ustvarjanja sličic ni bilo mogoče zagnati",
"lightboxTitle": "Raven predogleda v lightboxu",
"lightboxHelp": "Ko je omogočeno, lightbox namesto polnega originala (pogosto 512 MB) naloži JPEG predogled z ohranjenim razmerjem okoli 1920 px (običajno 200500 KB). Originali so še vedno uporabljeni, ko gost klikne Prenesi. Na disku to pomeni približno eno dodatno predogledno datoteko na fotografijo; predogledi se ustvarijo po potrebi ob prvem odpiranju in shranijo v /previews.",
"lightboxToggle": "Uporabi predoglede srednje ločljivosti v lightboxu",
"lightboxToggleHelp": "Privzeto izklopljeno. Vklopite, ko presodite, da je boljša zaznana hitrost vredna dodatne porabe diska.",
"lightboxHelp": "Lightbox prikaže JPEG z ohranjenim razmerjem okoli 1920 px (običajno 200500 KB) namesto polnega originala (pogosto 512 MB). Originali so še vedno uporabljeni, ko gost klikne Prenesi. Predogledi na disku pomenijo približno eno dodatno datoteko na fotografijo in so shranjeni v /previews.",
"lightboxToggle": "Omogoči vnaprejšnje ustvarjanje predogledov",
"lightboxToggleHelp": "Privzeto izklopljeno: vsak predogled nastane, ko gost fotografijo prvič odpre. Vklop odklene spodnji gumb, ki jih ustvari vse vnaprej.",
"regeneratePreviewsButton": "Ponovno ustvari vse predoglede",
"previewsRegenerateStarted": "Ponovno ustvarjanje predogledov lightbox se je začelo",
"previewsRegenerateError": "Ponovnega ustvarjanja predogledov ni bilo mogoče zagnati",
+5 -2
View File
@@ -155,13 +155,16 @@ export interface Photo {
hero_url?: string; // Hero-optimized image URL (1920x1080) for full-width hero sections
// Lightbox preview URL (#492). Set only when the admin has flipped
// lightbox_preview_enabled in Settings → Thumbnails. Aspect-preserved
// ≤1920px JPEG; the lightbox prefers it over `url` for image photos
// and falls back to `url` when null (off, video, or not yet generated).
// ≤1920px JPEG.
preview_url?: string | null;
// Aspect-preserved ≤1920px source for the fullscreen slideshow (#1015).
// Always set for image photos, unlike `preview_url` — the slideshow must
// never fall back to `hero_url`, which is a 16:9 centre crop and makes
// the "Black Bars (No crop)" fit letterbox an already-cropped frame.
//
// The lightbox takes it as its second choice for the same reason (#1166):
// the same /preview/:id URL, so an install that never flipped the toggle
// stops serving multi-megabyte originals to display a photo on screen.
slideshow_url?: string | null;
secure_url_template?: string;
download_url_template?: string;