77953c15c1
* 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 <paul@MacStudio-von-Paul.local>
117 lines
5.0 KiB
JavaScript
117 lines
5.0 KiB
JavaScript
/**
|
|
* 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();
|
|
});
|
|
});
|