diff --git a/backend/__tests__/integration/previewTiers.test.js b/backend/__tests__/integration/previewTiers.test.js new file mode 100644 index 00000000..648ca2e8 --- /dev/null +++ b/backend/__tests__/integration/previewTiers.test.js @@ -0,0 +1,191 @@ +/** + * Responsive preview tiers (#1095). + * + * A phone can display ~1170px at most, so the single 1920px preview ships + * roughly twice the bytes it can use on every lightbox swipe — and the + * lightbox prefetches neighbours, so a guest flicking through a wedding + * gallery on cellular pays that repeatedly. + * + * The width is whitelisted rather than free-form: every distinct value is a + * permanent cache entry on disk, so an open ?w= is an invitation to fill the + * volume with renditions nobody asked for. + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +process.env.NODE_ENV = 'test'; +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-tiers-')); +process.env.TEST_DATABASE_PATH = path.join(tmpRoot, 'db.sqlite'); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'tiers-test-secret'; +process.env.STORAGE_PATH = path.join(tmpRoot, 'storage'); +fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true }); + +const sharp = require('sharp'); +const imageProcessor = require('../../src/services/imageProcessor'); +const { bootCrmDb } = require('./helpers/crmDb'); + +let db; let cleanup; + +describe('preview tiers (#1095)', () => { + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + }, 120000); + + afterAll(async () => { + if (cleanup) await cleanup(); + await fs.promises.rm(tmpRoot, { recursive: true, force: true }).catch(() => {}); + }); + + describe('normalizeTierWidth', () => { + const { normalizeTierWidth, PREVIEW_WIDTHS, THUMBNAIL_WIDTHS } = imageProcessor; + + it('accepts every advertised width', () => { + for (const w of PREVIEW_WIDTHS) { + expect(normalizeTierWidth(String(w), PREVIEW_WIDTHS)).toBe(w); + } + for (const w of THUMBNAIL_WIDTHS) { + expect(normalizeTierWidth(String(w), THUMBNAIL_WIDTHS)).toBe(w); + } + }); + + it('rejects anything not on the list', () => { + // The disk-filling cases: arbitrary sizes, and a caller walking a range. + for (const bad of ['999', '1921', '0', '-100', '99999']) { + expect(normalizeTierWidth(bad, PREVIEW_WIDTHS)).toBeNull(); + } + }); + + it('rejects junk without throwing', () => { + // Straight off a query string, so it is whatever the client sent. + for (const bad of [undefined, null, '', 'abc', '12abc', {}, [], '1e3', 'NaN']) { + expect(normalizeTierWidth(bad, PREVIEW_WIDTHS)).toBeNull(); + } + }); + + it('does not let a thumbnail width through the preview list', () => { + // The two lists are separate on purpose; 600 is a thumb tier, not a + // preview tier, and vice versa for 1280. + expect(normalizeTierWidth('600', PREVIEW_WIDTHS)).toBeNull(); + expect(normalizeTierWidth('1280', THUMBNAIL_WIDTHS)).toBeNull(); + }); + }); + + describe('ensurePreviewImageAtWidth', () => { + async function seedPhoto() { + const [e] = await db('events').insert({ + slug: `tier-${Math.random().toString(36).slice(2, 8)}`, + event_type: 'wedding', + event_name: 'tier', + event_date: '2026-01-01', + host_email: 'h@example.com', + admin_email: 'a@example.com', + password_hash: 'x', + share_link: `tier-${Math.random()}`, + expires_at: new Date().toISOString(), + }).returning('id'); + const eventId = typeof e === 'object' ? e.id : e; + + // A real image on disk under STORAGE_PATH, since the managed branch + // resolves through storage rather than a mount. + const rel = `events/active/tier/${Math.random().toString(36).slice(2, 8)}.jpg`; + const abs = path.join(process.env.STORAGE_PATH, rel); + await fs.promises.mkdir(path.dirname(abs), { recursive: true }); + await sharp({ create: { width: 3000, height: 2000, channels: 3, background: { r: 10, g: 90, b: 160 } } }) + .jpeg().toFile(abs); + + const [p] = await db('photos').insert({ + event_id: eventId, + filename: path.basename(rel), + path: rel.replace(/^events\/active\//, ''), + type: 'individual', + width: 3000, + height: 2000, + processing_status: 'complete', + source_origin: 'managed', + }).returning('id'); + return db('photos').where({ id: typeof p === 'object' ? p.id : p }).first(); + } + + it('scopes keys by photo id so two galleries cannot collide', async () => { + // The leak: managed auto-imports keep camera basenames, so two events can + // each hold an IMG_0001.jpg. A tier is served straight from a cache hit + // without re-reading the source, so a shared key hands one gallery's + // photo to another. + const a = await seedPhoto(); + const b = await seedPhoto(); + await db('photos').where({ id: a.id }).update({ path: 'wedding-a/IMG_0001.jpg' }); + await db('photos').where({ id: b.id }).update({ path: 'wedding-b/IMG_0001.jpg' }); + + const keyA = imageProcessor.previewTierKeys(await db('photos').where({ id: a.id }).first())[0]; + const keyB = imageProcessor.previewTierKeys(await db('photos').where({ id: b.id }).first())[0]; + + expect(keyA).not.toBe(keyB); + expect(keyA).toContain(`p${a.id}_`); + expect(keyB).toContain(`p${b.id}_`); + }); + + it('derives every non-default tier key for cleanup', () => { + // Tiers live outside preview_path, so delete/archive/regenerate have no + // other way to find them. 1920 is excluded because that IS preview_path. + const keys = imageProcessor.previewTierKeys({ id: 5, path: 'e/a.jpg', source_origin: 'managed' }); + expect(keys).toHaveLength(imageProcessor.PREVIEW_WIDTHS.length - 1); + expect(keys.some((k) => k.includes('w1920'))).toBe(false); + expect(keys.every((k) => k.includes('p5_'))).toBe(true); + }); + + it('deletePreviewTiers removes generated tiers from storage', async () => { + const photo = await seedPhoto(); + const key = await imageProcessor.ensurePreviewImageAtWidth(photo, 640); + const abs = path.join(process.env.STORAGE_PATH, key); + expect(fs.existsSync(abs)).toBe(true); + + await imageProcessor.deletePreviewTiers(await db('photos').where({ id: photo.id }).first()); + expect(fs.existsSync(abs)).toBe(false); + }); + + it('produces a distinct key per width and never touches preview_path', async () => { + const photo = await seedPhoto(); + + const small = await imageProcessor.ensurePreviewImageAtWidth(photo, 640); + expect(small).toContain('preview_w640_'); + + // The extra tiers are cache, not state. Writing them to the row would + // mean the last size requested silently becomes "the" preview. + const row = await db('photos').where({ id: photo.id }).first(); + expect(row.preview_path == null || !String(row.preview_path).includes('w640')).toBe(true); + }); + + it('resolves the default width to the canonical preview, not a w1920 copy', async () => { + // Otherwise every existing install grows a duplicate of every preview it + // already has, for no benefit. + const photo = await seedPhoto(); + const def = await imageProcessor.ensurePreviewImageAtWidth(photo, 1920); + expect(def).not.toContain('preview_w1920_'); + }); + + it('reuses the cached tier instead of regenerating', async () => { + const photo = await seedPhoto(); + const first = await imageProcessor.ensurePreviewImageAtWidth(photo, 1280); + expect(first).toBeTruthy(); + + const abs = path.join(process.env.STORAGE_PATH, first); + const before = (await fs.promises.stat(abs)).mtimeMs; + await new Promise((r) => setTimeout(r, 20)); + + const second = await imageProcessor.ensurePreviewImageAtWidth(photo, 1280); + expect(second).toBe(first); + expect((await fs.promises.stat(abs)).mtimeMs).toBe(before); + }); + + it('actually resizes to the requested tier', async () => { + const photo = await seedPhoto(); + const key = await imageProcessor.ensurePreviewImageAtWidth(photo, 640); + const meta = await sharp(path.join(process.env.STORAGE_PATH, key)).metadata(); + // 3000x2000 constrained to a 640 long edge. + expect(Math.max(meta.width, meta.height)).toBe(640); + expect(meta.height).toBe(Math.round(640 * (2000 / 3000))); + }); + }); +}); diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index fd1aab8b..852f170b 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -677,6 +677,11 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos. if (photo.preview_path) { await storage.delete(photo.preview_path).catch(() => {}); } + // Outside the preview_path guard on purpose: a responsive tier (#1095) can + // exist when the canonical rendition never did — they are generated + // 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); // Delete pre-generated watermark if exists if (photo.watermark_path) { @@ -838,6 +843,9 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos if (photo.preview_path) { await storage.delete(photo.preview_path).catch(() => {}); } + // 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); if (photo.watermark_path) { await watermarkGeneratorService.deleteForPhoto(photo.id); } diff --git a/backend/src/routes/adminThumbnails.js b/backend/src/routes/adminThumbnails.js index c7d1e789..97a09fac 100644 --- a/backend/src/routes/adminThumbnails.js +++ b/backend/src/routes/adminThumbnails.js @@ -232,6 +232,12 @@ router.post('/regenerate-previews', adminAuth, requirePermission('photos.edit'), // Force regeneration regardless of existing preview state by // nulling the cached path so ensurePreviewImage doesn't // short-circuit on a stale isPreviewValid check. + // Drop the responsive tiers first (#1095). They are cached by width + // outside preview_path, so regenerating only the canonical rendition + // leaves phones served the stale 640/1280 copy indefinitely — which + // is precisely the case this endpoint exists for (a replaced + // reference source, or a corrupted rendition). + await require('../services/imageProcessor').deletePreviewTiers(photo); const newPreviewPath = await ensurePreviewImage({ ...photo, preview_path: null }); if (newPreviewPath) { successCount++; diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index e0a64f7f..69d4c1ec 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -2449,10 +2449,20 @@ router.get('/:slug/preview/:photoId', return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`)); } + // Responsive tier (#1095). Whitelisted only — an open ?w= would let + // anyone fill the disk with renditions nobody asked for. An unrecognised + // or absent value falls through to the canonical 1920 preview, so old + // clients and hand-typed URLs behave exactly as before. + const { PREVIEW_WIDTHS, normalizeTierWidth, ensurePreviewImageAtWidth } = + require('../services/imageProcessor'); + const tierWidth = normalizeTierWidth(req.query.w, PREVIEW_WIDTHS); + // Lazy generation: ensurePreviewImage returns null on any // failure (corrupt source, sharp OOM, storage unavailable, …). // Fall back to the original so the lightbox always renders. - const previewPath = await ensurePreviewImage(photo); + const previewPath = tierWidth + ? (await ensurePreviewImageAtWidth(photo, tierWidth)) || (await ensurePreviewImage(photo)) + : await ensurePreviewImage(photo); if (!previewPath) { logger.warn(`Failed to generate preview for photo ${photoId}, falling back to original`); return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`)); @@ -2472,7 +2482,10 @@ router.get('/:slug/preview/:photoId', const watermarkHash = watermarkSettings?.enabled ? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}` : '-nowm'; - const etag = `"preview-${photoId}-${mtimeMs}${watermarkHash}"`; + // Tier is part of the etag: without it a client that already holds the + // 1920 rendition would get a 304 for its 640 request and render the + // wrong size, which is the whole point of the feature inverted. + const etag = `"preview-${photoId}-${tierWidth || 'def'}-${mtimeMs}${watermarkHash}"`; if (req.headers['if-none-match'] === etag) { return res.status(304).end(); } diff --git a/backend/src/services/archiveService.js b/backend/src/services/archiveService.js index 331838f6..fad7d998 100644 --- a/backend/src/services/archiveService.js +++ b/backend/src/services/archiveService.js @@ -219,6 +219,9 @@ async function archiveEvent(event) { if (photo.preview_path) { await storage.delete(photo.preview_path).catch(() => {}); } + // 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); // Best effort: remove watermarked variants too if a refactor added them. if (photo.watermark_path) { await storage.delete(photo.watermark_path).catch(() => {}); diff --git a/backend/src/services/imageProcessor.js b/backend/src/services/imageProcessor.js index f87c3571..de7a0d1e 100644 --- a/backend/src/services/imageProcessor.js +++ b/backend/src/services/imageProcessor.js @@ -108,6 +108,23 @@ const DEFAULT_HERO_QUALITY = 85; const DEFAULT_PREVIEW_LONG_EDGE = 1920; const DEFAULT_PREVIEW_QUALITY = 85; +// Responsive tiers (#1095). A whitelist, not a free-form ?w=: an open +// parameter lets anyone fill the disk with renditions nobody asked for, and +// every distinct value is a permanent cache entry. +// +// 1920 stays the default so existing preview_path rows keep their meaning and +// nothing regenerates on upgrade. The smaller tiers exist because a phone can +// show ~1170px at most, so the 1920 tier ships roughly twice the bytes it can +// use on every lightbox swipe. +const PREVIEW_WIDTHS = [640, 1280, 1920]; +const THUMBNAIL_WIDTHS = [300, 600, 900]; + +/** Whitelist a requested width, or null. Callers treat null as "use default". */ +function normalizeTierWidth(requested, allowed) { + const n = parseInt(requested, 10); + return Number.isFinite(n) && allowed.includes(n) ? n : null; +} + // Helper to parse setting value (handles both JSON-encoded and plain values) function parseSettingValue(value) { if (value === null || value === undefined) { @@ -578,7 +595,12 @@ async function ensureHeroImage(photo) { */ async function generatePreviewImage(imagePath, options = {}) { const filename = options.outputBasename || path.basename(imagePath); - const previewFilename = `preview_${filename}`; + // Non-default tiers get their own key so they cannot collide with the + // canonical preview the DB column points at. + const widthTag = options.longEdge && options.longEdge !== DEFAULT_PREVIEW_LONG_EDGE + ? `w${options.longEdge}_` + : ''; + const previewFilename = `preview_${widthTag}${filename}`; const previewRelKey = path.posix.join('previews', previewFilename); const storage = getStorage(); @@ -666,6 +688,113 @@ async function isPreviewValid(previewPath) { * withLocalCopy, and the throw put every lightbox open back on the full-size * original — the exact cost the preview tier (#492) exists to avoid. */ +/** + * A preview at a specific tier width (#1095). + * + * Deliberately separate from ensurePreviewImage rather than a parameter on it. + * That function owns photos.preview_path — one column, one canonical rendition + * — and threading a width through it would either overwrite that column with + * whatever size was asked for last, or need a column per tier. Extra tiers are + * pure cache instead: keyed by width, looked up in storage, generated on miss, + * never written to the row. + * + * Returns null on anything unexpected so callers fall back to the default + * tier, which is always the honest thing to serve. + */ +/** + * Storage keys for every responsive tier of a photo (#1095). + * + * Tiers live outside photos.preview_path deliberately — that column owns the + * canonical rendition — but that also means nothing else knows they exist. + * Delete, bulk-delete, archive and regenerate all operate on preview_path + * alone, so without this the tiers survive their own photo: orphaned on disk + * forever after a delete, and served stale forever after a regenerate. + * + * Derived rather than tracked: the key scheme is deterministic, so there is + * nothing to keep in sync and no migration. + */ +function previewTierKeys(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 PREVIEW_WIDTHS + .filter((w) => w !== DEFAULT_PREVIEW_LONG_EDGE) + .map((w) => path.posix.join('previews', `preview_w${w}_${outputBasename}`)); +} + +/** Best-effort removal of every responsive tier for a photo. */ +async function deletePreviewTiers(photo) { + const storage = getStorage(); + await Promise.all(previewTierKeys(photo).map((k) => storage.delete(k).catch(() => {}))); +} + +async function ensurePreviewImageAtWidth(photo, width) { + if (!width || width === DEFAULT_PREVIEW_LONG_EDGE) return ensurePreviewImage(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}` + ); + // ALWAYS scoped by photo id, managed rows included. Basenames are not unique + // across events — two galleries can each hold an IMG_0001.jpg — and because a + // tier is served straight from a cache hit without re-reading the source, a + // collision hands one gallery's photo to another. Scoping by id is what makes + // the cache safe to trust; it is not a tidiness choice. + const outputBasename = `p${photo.id}_${sourceBasename}`; + const key = path.posix.join('previews', `preview_w${width}_${outputBasename}`); + + // Cache hit: nothing to do. This is the common path once a gallery has been + // browsed at a given size. + try { + if (await storage.stat(key)) return key; + } catch (e) { + // fall through and regenerate + } + + try { + if (isExternal) { + const localPath = resolvePhotoFilePath(event, photo); + return await generatePreviewImage(localPath, { + regenerate: true, outputBasename, longEdge: width, + }); + } + const sourceKey = resolvePhotoStorageKey(event, photo); + if (!sourceKey) return null; + return await withLocalCopy(sourceKey, async (localPath) => { + const proc = await withProcessableImage(localPath, sourceKey); + try { + // outputBasename, not proc.outputBasename: the RAW path returns the + // source basename, which would drop the photo-id scoping above and + // reintroduce the cross-gallery collision. + return await generatePreviewImage(proc.path, { + regenerate: true, + outputBasename, + longEdge: width, + }); + } finally { + proc.cleanup(); + } + }); + } catch (e) { + logger.warn(`Preview tier w${width} failed for photo ${photo.id}: ${e.message}`); + return null; + } +} + async function ensurePreviewImage(photo) { const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver'); @@ -851,6 +980,12 @@ async function resizeToBox(inputBuffer, box, options = {}) { } module.exports = { + ensurePreviewImageAtWidth, + previewTierKeys, + deletePreviewTiers, + PREVIEW_WIDTHS, + THUMBNAIL_WIDTHS, + normalizeTierWidth, resizeToBox, generateThumbnail, isThumbnailValid, diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index db9e1ae3..f40b6e9b 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -6,6 +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 { feedbackService } from '../../services/feedback.service'; import { galleryService } from '../../services/gallery.service'; import { FeedbackIdentityModal } from './FeedbackIdentityModal'; @@ -1006,7 +1007,7 @@ export const PhotoLightbox: React.FC = ({ // 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={photo.preview_url || photo.url} + src={previewUrlForViewport(photo.preview_url, photo) || photo.url} alt={photo.filename} fallbackSrc={photo.thumbnail_url || undefined} className="max-w-full max-h-full object-contain select-none pointer-events-none" @@ -1031,7 +1032,7 @@ export const PhotoLightbox: React.FC = ({ { + setViewport(realWidth, realDpr, undefined); + vi.restoreAllMocks(); +}); + +describe('viewportPreviewWidth', () => { + it('picks the smallest tier that still covers the device', () => { + setViewport(390, 3); // 1170 device px on the long edge — a DPR-3 phone + expect(viewportPreviewWidth(LANDSCAPE)).toBe(1280); + + setViewport(375, 1); // 375 — an old phone + expect(viewportPreviewWidth(LANDSCAPE)).toBe(640); + + setViewport(1440, 2, undefined, 900); // beyond the top tier + expect(viewportPreviewWidth(LANDSCAPE)).toBe(1920); + }); + + it('never returns a tier smaller than the device needs', () => { + // Undershooting is the one failure that is actually visible: a blurry + // lightbox. Overshooting only costs bytes. + for (const [w, dpr] of [[320, 1], [390, 3], [768, 2], [1024, 1], [1440, 2]] as const) { + setViewport(w, dpr, undefined, Math.round(w * 2)); + const scale = Math.min(w / LANDSCAPE.width, (w * 2) / LANDSCAPE.height); + const needed = Math.round(LANDSCAPE.width * scale * Math.min(dpr, 3)); + const picked = viewportPreviewWidth(LANDSCAPE); + expect(picked >= needed || picked === PREVIEW_WIDTHS[PREVIEW_WIDTHS.length - 1]).toBe(true); + } + }); + + it('caps devicePixelRatio so an absurd DPR cannot skip straight to the top', () => { + // Uncapped this would ask for 3900 device px and land on the 1920 tier — + // i.e. a phone pulling the desktop rendition, which is the bug. Capped at + // 3 it asks for 1170 and takes 1280, the same as a normal DPR-3 phone. + setViewport(390, 10); + expect(viewportPreviewWidth(LANDSCAPE)).toBe(1280); + }); +}); + +describe('long-edge sizing (the server bounds the LONG edge, not the width)', () => { + it('gives a portrait photo a bigger tier than a landscape one on the same phone', () => { + // 390x844 at DPR 3. A 2:3 portrait is bound by height and renders ~1755 + // device px on its long edge; a 3:2 landscape is bound by width and needs + // ~1170. Sizing from viewport WIDTH alone gives both 1280 and makes every + // portrait softer than it is today — the regression this guards. + setViewport(390, 3); + expect(viewportPreviewWidth({ width: 2000, height: 3000 })).toBe(1920); + expect(viewportPreviewWidth({ width: 3000, height: 2000 })).toBe(1280); + }); + + it('falls back to the top tier when dimensions are unknown', () => { + // No geometry to reason about, so serve what is served today rather than + // guessing small and shipping a blurry lightbox. + setViewport(390, 3); + expect(viewportPreviewWidth(undefined)).toBe(1920); + expect(viewportPreviewWidth({ width: null, height: null })).toBe(1920); + }); +}); + +describe('previewUrlForViewport', () => { + it('leaves the URL untouched at the default tier', () => { + // Byte-identical URLs at 1920 keep every existing cache entry and ETag + // valid, so desktop users see no change at all. + setViewport(1920, 2, undefined, 1080); + expect(previewUrlForViewport('/api/gallery/x/preview/7', LANDSCAPE)).toBe('/api/gallery/x/preview/7'); + }); + + it('appends a width below the default tier', () => { + setViewport(390, 3); + expect(previewUrlForViewport('/api/gallery/x/preview/7', LANDSCAPE)).toBe('/api/gallery/x/preview/7?w=1280'); + }); + + it('preserves an existing query string', () => { + setViewport(375, 1); + expect(previewUrlForViewport('/api/gallery/x/preview/7?token=abc', LANDSCAPE)) + .toBe('/api/gallery/x/preview/7?token=abc&w=640'); + }); + + it('returns null for a null preview so the caller falls back to the original', () => { + // A null preview_url means the lightbox is about to serve the untouched + // original; hanging ?w= on that would claim a resize that never happened. + setViewport(390, 3); + expect(previewUrlForViewport(null)).toBeNull(); + expect(previewUrlForViewport(undefined)).toBeNull(); + expect(previewUrlForViewport('')).toBeNull(); + }); + + it('downshifts one tier on save-data', () => { + setViewport(390, 3, { saveData: true }); + expect(previewUrlForViewport('/p', LANDSCAPE)).toBe('/p?w=640'); + }); + + it('downshifts one tier on a slow connection', () => { + setViewport(390, 3, { effectiveType: '3g' }); + expect(previewUrlForViewport('/p', LANDSCAPE)).toBe('/p?w=640'); + }); + + it('ignores a healthy connection object', () => { + setViewport(390, 3, { effectiveType: '4g', saveData: false }); + expect(previewUrlForViewport('/p', LANDSCAPE)).toBe('/p?w=1280'); + }); + + it('never downshifts below the smallest tier', () => { + setViewport(320, 1, { saveData: true }); // already at 640 + expect(previewUrlForViewport('/p', LANDSCAPE)).toBe('/p?w=640'); + }); +}); diff --git a/frontend/src/components/gallery/imageTiers.ts b/frontend/src/components/gallery/imageTiers.ts new file mode 100644 index 00000000..22bc3430 --- /dev/null +++ b/frontend/src/components/gallery/imageTiers.ts @@ -0,0 +1,105 @@ +/** + * Responsive image tiers (#1095). + * + * PicPeak serves the same bytes to a 375px phone as to a 4K desktop. The + * preview tier is a single 1920px JPEG, but a phone can display ~1170px at + * most — so every lightbox swipe pulls roughly twice the pixels it can use, + * and the lightbox prefetches neighbours, which multiplies it. + * + * The widths mirror the backend whitelist (imageProcessor.js). They are + * duplicated rather than fetched because they are a contract, not + * configuration: a value the server does not recognise is ignored and the + * default tier served, so drift degrades to today's behaviour rather than + * breaking. The backend test pins the same lists. + */ + +export const PREVIEW_WIDTHS = [640, 1280, 1920] as const; + +// Thumbnail tiers are NOT here yet. Emitting a srcset whose candidates the +// server ignores is worse than emitting none: the browser would pick the +// "600w" candidate, receive the 300px image, and upscale it — the exact +// softness #1095 reports, made slightly worse. generateThumbnail resolves its +// width from admin settings rather than an argument, so tiering it is a +// separate change and lands separately. + +/** Smallest tier that still covers `needed`, or the largest if none does. */ +function smallestCovering(needed: number, tiers: readonly number[]): number { + return tiers.find((w) => w >= needed) ?? tiers[tiers.length - 1]; +} + +/** + * Device pixels the image's LONG EDGE will occupy, capped. + * + * The long edge specifically, because that is what the server's `w` bounds: + * it resizes with fit:'inside', so `w` caps both dimensions. Sizing from + * viewport WIDTH alone undersizes portraits — on a 390x844 phone at DPR 3 a + * 2:3 photo is contained by height and renders ~1755 device px tall, so + * picking by width lands on 1280 and makes portrait photos softer than they + * are today. Landscape on the same phone genuinely needs only ~1170. + * + * DPR is capped at 3: uncapped, a DPR-10 device asks for thousands of pixels + * and lands back on the desktop rendition, which is the thing being fixed. + * + * Without photo dimensions there is nothing to reason about, so it falls back + * to the largest edge the viewport could demand — i.e. today's behaviour. + */ +export function viewportPreviewWidth(photo?: { width?: number | null; height?: number | null }): number { + if (typeof window === 'undefined') return PREVIEW_WIDTHS[PREVIEW_WIDTHS.length - 1]; + const dpr = Math.min(window.devicePixelRatio || 1, 3); + const vw = window.innerWidth; + const vh = window.innerHeight; + + const pw = photo?.width; + const ph = photo?.height; + if (!pw || !ph) { + return smallestCovering(Math.round(Math.max(vw, vh) * dpr), PREVIEW_WIDTHS); + } + + // Contained in the viewport, so one axis binds; the rendered long edge is + // the source long edge times that scale. + const scale = Math.min(vw / pw, vh / ph); + return smallestCovering(Math.round(Math.max(pw, ph) * scale * dpr), PREVIEW_WIDTHS); +} + +/** + * Downshift one tier when the browser says the connection is poor or the user + * asked for less data. Both signals are Chromium-only and absent on Safari, so + * this is a bonus rather than the mechanism — the viewport cap above is what + * does the real work. + */ +function applyDataSaver(width: number, tiers: readonly number[]): number { + const conn = (navigator as unknown as { + connection?: { saveData?: boolean; effectiveType?: string }; + }).connection; + if (!conn) return width; + + const slow = conn.effectiveType === '2g' || conn.effectiveType === 'slow-2g' + || conn.effectiveType === '3g'; + if (!conn.saveData && !slow) return width; + + const i = tiers.indexOf(width); + return i > 0 ? tiers[i - 1] : width; +} + +/** Append ?w= to a derivative URL, preserving any existing query string. */ +function withWidth(url: string, width: number): string { + return `${url}${url.includes('?') ? '&' : '?'}w=${width}`; +} + +/** + * The preview URL sized for this device. Returns the input untouched when + * there is nothing to size — a null preview_url means the caller is about to + * fall back to the original, and adding ?w= to that would be a lie. + */ +export function previewUrlForViewport( + previewUrl: string | null | undefined, + photo?: { width?: number | null; height?: number | null }, +): string | null { + if (!previewUrl) return null; + const width = applyDataSaver(viewportPreviewWidth(photo), PREVIEW_WIDTHS); + // The top tier is the default the server already serves; leaving the + // parameter off keeps those URLs byte-identical to today's, so existing + // caches and ETags stay valid. + if (width === PREVIEW_WIDTHS[PREVIEW_WIDTHS.length - 1]) return previewUrl; + return withWidth(previewUrl, width); +}