diff --git a/backend/__tests__/integration/slideshowPhotoSource.test.js b/backend/__tests__/integration/slideshowPhotoSource.test.js new file mode 100644 index 00000000..f141c8be --- /dev/null +++ b/backend/__tests__/integration/slideshowPhotoSource.test.js @@ -0,0 +1,150 @@ +/** + * Slideshow photo source (#1015). + * + * The bug: with `lightbox_preview_enabled` off (the default), /photos emitted + * `preview_url: null`, so the slideshow's `preview_url || hero_url || url` + * chain fell through to `hero_url` — a 1920x1080 `fit: 'cover'` centre crop + * meant for gallery header banners. With the "Black Bars (No crop)" fit the + * show then letterboxed an already-cropped frame: portrait photos lost their + * top and bottom and the setting looked broken. + * + * The contract pinned here: `slideshow_url` points at the aspect-preserved + * preview tier and is emitted for image photos REGARDLESS of the lightbox + * toggle, so the slideshow never has a reason to reach for `hero_url`. + * `preview_url` itself must stay gated — the lightbox opt-in is unchanged. + */ + +const request = require('supertest'); +const express = require('express'); +const cookieParser = require('cookie-parser'); +const jwt = require('jsonwebtoken'); + +const { bootCrmDb, seedMinimal } = require('./helpers/crmDb'); + +process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-src-test-secret'; + +const SLUG = 'slideshow-source-event'; + +describe('Slideshow photo source (#1015)', () => { + let db; + let cleanup; + let app; + let eventId; + let imagePhotoId; + let videoPhotoId; + + const galleryToken = () => jwt.sign( + { eventId, eventSlug: SLUG, type: 'gallery' }, + process.env.JWT_SECRET, + { expiresIn: '1h', issuer: 'picpeak-auth' } + ); + + const setLightboxPreview = async (on) => { + await db('app_settings').where({ setting_key: 'lightbox_preview_enabled' }).del(); + await db('app_settings').insert({ + setting_key: 'lightbox_preview_enabled', + setting_value: JSON.stringify(on), + setting_type: 'general', + updated_at: new Date().toISOString(), + }); + }; + + const fetchPhotos = async () => { + const res = await request(app) + .get(`/api/gallery/${SLUG}/photos`) + .set('Authorization', `Bearer ${galleryToken()}`) + .expect(200); + return res.body.photos; + }; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + await seedMinimal(db); + + const inserted = await db('events').insert({ + slug: SLUG, + event_type: 'wedding', + event_name: 'Slideshow Source Test', + event_date: '2026-08-01', + host_email: 'host@example.com', + admin_email: 'admin@example.com', + password_hash: 'x', + share_link: `/gallery/${SLUG}/share`, + share_token: 'slideshow-source-share', + expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(), + is_active: 1, + is_archived: 0, + is_draft: 0, + created_at: new Date().toISOString(), + }).returning('id'); + eventId = inserted[0]?.id ?? inserted[0]; + + const img = await db('photos').insert({ + event_id: eventId, + filename: 'portrait.jpg', + path: 'events/slideshow-source/portrait.jpg', + type: 'individual', + mime_type: 'image/jpeg', + uploaded_at: new Date().toISOString(), + }).returning('id'); + imagePhotoId = img[0]?.id ?? img[0]; + + const vid = await db('photos').insert({ + event_id: eventId, + filename: 'clip.mp4', + path: 'events/slideshow-source/clip.mp4', + type: 'individual', + media_type: 'video', + mime_type: 'video/mp4', + uploaded_at: new Date().toISOString(), + }).returning('id'); + videoPhotoId = vid[0]?.id ?? vid[0]; + + app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use('/api/gallery', require('../../src/routes/gallery')); + }, 120000); + + afterAll(async () => { + if (cleanup) await cleanup(); + }); + + it('emits slideshow_url for image photos even when lightbox previews are OFF', async () => { + await setLightboxPreview(false); + const photos = await fetchPhotos(); + const image = photos.find((p) => p.id === imagePhotoId); + + expect(image.slideshow_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`); + // The regression: this is what used to be null, pushing the show to hero. + expect(image.preview_url).toBeNull(); + }); + + it('leaves preview_url gated so the lightbox opt-in is unchanged', async () => { + await setLightboxPreview(true); + const photos = await fetchPhotos(); + const image = photos.find((p) => p.id === imagePhotoId); + + expect(image.preview_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`); + expect(image.slideshow_url).toBe(image.preview_url); + }); + + it('never points the slideshow at the cover-cropped hero tier', async () => { + await setLightboxPreview(false); + const photos = await fetchPhotos(); + const image = photos.find((p) => p.id === imagePhotoId); + + // hero_url still ships (the gallery header uses it) — it just must not be + // what the slideshow resolves to. + expect(image.hero_url).toBe(`/api/gallery/${SLUG}/hero/${imagePhotoId}`); + expect(image.slideshow_url).not.toBe(image.hero_url); + }); + + it('emits slideshow_url: null for videos, which have no preview tier', async () => { + await setLightboxPreview(false); + const photos = await fetchPhotos(); + const video = photos.find((p) => p.id === videoPhotoId); + + expect(video.slideshow_url).toBeNull(); + }); +}); diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 196cb746..420808eb 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -726,6 +726,17 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res) && (!photo.mime_type || !photo.mime_type.startsWith('video/')) ? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}` : null, + // Slideshow source (#1015). Same preview tier, but emitted + // unconditionally: the slideshow has no `url` fallback worth + // taking (originals are projector-sized) and must never land on + // `hero_url`, which is cover-cropped to 16:9 — that made the + // "no crop" fit letterbox an already-cropped frame. The preview + // route generates lazily and redirects to the original on any + // failure, so this is safe even where no preview exists yet. + slideshow_url: photo.media_type !== 'video' + && (!photo.mime_type || !photo.mime_type.startsWith('video/')) + ? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}` + : null, secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`, download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`, type: photo.type, diff --git a/frontend/src/pages/gallery/SlideshowPage.tsx b/frontend/src/pages/gallery/SlideshowPage.tsx index 540491b3..0d097055 100644 --- a/frontend/src/pages/gallery/SlideshowPage.tsx +++ b/frontend/src/pages/gallery/SlideshowPage.tsx @@ -23,8 +23,15 @@ const STATE_POLL_MS = 3000; // Prefer the aspect-preserved preview (≤1920px) over the full original; fall // back to the standard url. Always absolutised so it works whether the API is // same-origin or an explicit absolute base. +// +// Deliberately never `hero_url` (#1015): that tier is cover-cropped to 16:9 +// for gallery header banners, so with fit='contain' the show letterboxed an +// already-cropped frame — portrait photos lost their top and bottom and the +// "Black Bars (No crop)" setting looked broken. `slideshow_url` is the same +// aspect-preserved preview as `preview_url` but is always emitted, so the +// crop can't come back when lightbox previews are off (the default). function photoSrc(photo: Photo): string { - return buildResourceUrl(photo.preview_url || photo.hero_url || photo.url); + return buildResourceUrl(photo.slideshow_url || photo.preview_url || photo.url); } // CSS `filter` applied directly to the image for filters that are pure tone diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index eefbb05b..5fac65a7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -116,6 +116,11 @@ export interface Photo { // ≤1920px JPEG; the lightbox prefers it over `url` for image photos // and falls back to `url` when null (off, video, or not yet generated). 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. + slideshow_url?: string | null; secure_url_template?: string; download_url_template?: string; requires_token?: boolean;