feat(gallery): sized preview tiers so phones stop pulling 1920px (#1095) (#1099)

* feat(gallery): sized preview tiers so phones stop pulling 1920px (#1095)

A phone can display ~1170px at most, but the preview tier is a single
1920px JPEG with no size parameter — so every lightbox swipe ships
roughly twice the bytes it can use, and the slide track preloads
neighbours, which multiplies it. On the reporter's all-external install
a null preview_url falls back to the untouched NAS original, which makes
it worse again.

Backend: ?w= on the gallery preview route, whitelisted to 640/1280/1920.
A whitelist rather than a free-form width because every distinct value
is a permanent rendition on disk — an open parameter is an invitation to
fill the volume. Unrecognised or absent values fall through to the
canonical 1920 preview, so old clients and hand-typed URLs behave
exactly as today.

Extra tiers are cache, not state: ensurePreviewImageAtWidth keys them by
width, looks them up in storage and generates on miss, and never writes
photos.preview_path. That column owns the canonical rendition, and
threading a width through it would mean the last size anyone requested
silently becomes "the" preview. Requesting 1920 resolves to the existing
preview rather than a w1920 duplicate, so no install grows a second copy
of every preview it already has.

The tier is part of the ETag. Without it a client holding the 1920
rendition gets a 304 for its 640 request and renders the wrong size,
which is this feature inverted.

Frontend: the lightbox picks a tier from innerWidth x devicePixelRatio,
capped at DPR 3 — uncapped, a DPR-10 device asks for 3900px and lands
straight back on the desktop rendition. At the top tier the URL is left
byte-identical so existing caches and ETags stay valid and desktop sees
no change at all. saveData and a 2g/3g effectiveType drop one tier;
both are Chromium-only, so they are a bonus rather than the mechanism.

Grid thumbnails are NOT tiered here, deliberately. generateThumbnail
resolves its width from admin settings rather than an argument, so
tiering it is a separate change — and shipping a srcset whose candidates
the server ignores would be worse than shipping none: the browser would
take the "600w" candidate, receive the 300px image and upscale it, which
is the reported softness made slightly worse. That half of #1095 lands
separately.

* fix(gallery): scope tier keys per photo, size by long edge, clean up tiers

External review. Three findings against the tier work, one a
cross-gallery leak.

The tier cache key was the photo's BASENAME. Managed uploads keep camera
basenames, so two events can each hold an IMG_0001.jpg — and a tier is
served straight from a cache hit without re-reading the source, so the
second gallery gets the first gallery's photo. Keys are now scoped by
photo id for every source type. The RAW branch passed proc.outputBasename,
which would have dropped that scoping again; it now passes the scoped name.

Tier selection used viewport WIDTH, but ?w= bounds the LONG edge
(fit:'inside'). On a 390x844 phone at DPR 3 a 2:3 portrait is bound by
height and renders ~1755 device px, so width-only picked 1280 and made
portraits softer than today; landscape on the same phone needs ~1170. It
now computes the rendered long edge from the photo's own dimensions and
falls back to the top tier — today's behaviour — when they are unknown.

Tiers live outside photos.preview_path, so nothing else knew they
existed: delete, bulk-delete and archive left them orphaned in previews/
forever, and regenerate-previews refreshed only the canonical rendition
while phones kept the stale copy. previewTierKeys derives them from the
same deterministic scheme and all four paths clean up. Deliberately
outside the preview_path guard — a tier can exist when the canonical
rendition never did, so keying cleanup off preview_path would strand
precisely the photos only ever viewed on a phone.

The existing tier tests encoded the old width-only semantics and were
updated rather than kept; that is a behaviour change, not a test fix.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
This commit is contained in:
Paul Nothaft
2026-08-20 14:08:07 +02:00
committed by GitHub
parent 0b886ed942
commit 011f6ae7ec
9 changed files with 600 additions and 5 deletions
@@ -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)));
});
});
});
+8
View File
@@ -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);
}
+6
View File
@@ -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++;
+15 -2
View File
@@ -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();
}
+3
View File
@@ -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(() => {});
+136 -1
View File
@@ -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,
@@ -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<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={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<PhotoLightboxProps> = ({
<AuthenticatedImage
// Same preview-prefer-with-fallback logic as the
// off-screen tile above (#492).
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"
@@ -0,0 +1,133 @@
/**
* Preview tier selection (#1095).
*
* The point of the feature is that a phone stops pulling a 1920px rendition it
* cannot display. These pin the selection maths, because getting it subtly
* wrong is invisible the image still renders, just at the wrong size, and
* the only symptom is a data bill.
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { previewUrlForViewport, viewportPreviewWidth, PREVIEW_WIDTHS } from '../imageTiers';
const realWidth = window.innerWidth;
const realDpr = window.devicePixelRatio;
function setViewport(width: number, dpr: number, connection?: unknown, 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: connection, configurable: true });
}
// The lightbox always passes real dimensions; these stand in for a typical
// landscape frame so the tier is chosen from geometry rather than the
// unknown-dimensions fallback.
const LANDSCAPE = { width: 3000, height: 2000 };
afterEach(() => {
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');
});
});
@@ -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);
}