The slideshow resolved its image as preview_url || hero_url || url. preview_url is only emitted when lightbox_preview_enabled is on (default false), so a default install fell through to hero_url — the 1920x1080 fit:'cover' centre crop built for gallery header banners. object-fit: contain then letterboxed an already-cropped 16:9 frame, so portrait photos lost their top and bottom and 'Black Bars (No crop)' looked inert. Emits slideshow_url (same aspect-preserved preview tier) unconditionally for image photos; the show prefers it and never falls back to hero_url. preview_url stays gated so the lightbox opt-in is unchanged. Fixes #1015.
This commit is contained in:
@@ -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: '[email protected]',
|
||||||
|
admin_email: '[email protected]',
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -959,6 +959,17 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
|||||||
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
|
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
|
||||||
? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}`
|
? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}`
|
||||||
: null,
|
: 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}}`,
|
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}}`,
|
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
|
||||||
type: photo.type,
|
type: photo.type,
|
||||||
|
|||||||
@@ -25,8 +25,15 @@ const STATE_POLL_MS = 3000;
|
|||||||
// Prefer the aspect-preserved preview (≤1920px) over the full original; fall
|
// 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
|
// back to the standard url. Always absolutised so it works whether the API is
|
||||||
// same-origin or an explicit absolute base.
|
// 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 {
|
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
|
// CSS `filter` applied directly to the image for filters that are pure tone
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ export interface Photo {
|
|||||||
// ≤1920px JPEG; the lightbox prefers it over `url` for image photos
|
// ≤1920px JPEG; the lightbox prefers it over `url` for image photos
|
||||||
// and falls back to `url` when null (off, video, or not yet generated).
|
// and falls back to `url` when null (off, video, or not yet generated).
|
||||||
preview_url?: string | null;
|
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;
|
secure_url_template?: string;
|
||||||
download_url_template?: string;
|
download_url_template?: string;
|
||||||
requires_token?: boolean;
|
requires_token?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user