* 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 <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
05e23ef1a1
commit
77953c15c1
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -570,16 +570,13 @@ async function isHeroValid(heroPath) {
|
||||
* Ensure a hero image exists for a photo, regenerate if needed
|
||||
*/
|
||||
async function ensureHeroImage(photo) {
|
||||
const { resolvePhotoStorageKey } = require('./photoResolver');
|
||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
||||
|
||||
let sourceKey;
|
||||
let event;
|
||||
try {
|
||||
const event = await db('events').where('id', photo.event_id).first();
|
||||
sourceKey = resolvePhotoStorageKey(event, photo);
|
||||
logger.info(`Ensuring hero image for photo ${photo.id} from key: ${sourceKey}`);
|
||||
event = await db('events').where('id', photo.event_id).first();
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) ? e.message : String(e);
|
||||
logger.error(`Failed to resolve original key for hero image (photo ${photo.id}): ${msg}`);
|
||||
logger.error(`Failed to load event for hero image (photo ${photo.id}): ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -591,7 +588,56 @@ async function ensureHeroImage(photo) {
|
||||
logger.warn(`Invalid hero image detected for photo ${photo.id}, regenerating...`);
|
||||
}
|
||||
|
||||
const newHeroPath = await withLocalCopy(sourceKey, async (localPath) => {
|
||||
// External sources never reach the managed backend, so resolvePhotoStorageKey
|
||||
// returns null for them by design — and this function used to feed that null
|
||||
// straight to withLocalCopy, which throws, so the hero route fell back to
|
||||
// redirecting at the full original. #1078 fixed exactly this for
|
||||
// ensurePreviewImage and nobody carried it across; it only became visible
|
||||
// when the Story hero started asking for hero_url instead of the original
|
||||
// (#1166), which on a reference-mode gallery quietly changed nothing.
|
||||
const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
|
||||
|
||||
let newHeroPath;
|
||||
if (isExternal) {
|
||||
// Mirrors the external branch in ensurePreviewImage: a direct fs read off
|
||||
// the mount, so no withLocalCopy, and a per-photo outputBasename so two
|
||||
// events referencing the same NAS basename cannot clobber each other.
|
||||
let localPath;
|
||||
try {
|
||||
localPath = resolvePhotoFilePath(event, photo);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to resolve external file for hero image (photo ${photo.id}): ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
const sourceBasename = path.basename(photo.external_relpath || photo.filename || `photo-${photo.id}`);
|
||||
newHeroPath = await generateHeroImage(localPath, {
|
||||
regenerate: true,
|
||||
outputBasename: `ext${photo.id}_${sourceBasename}`,
|
||||
});
|
||||
if (newHeroPath) {
|
||||
await db('photos').where({ id: photo.id }).update({ hero_path: newHeroPath });
|
||||
}
|
||||
return newHeroPath;
|
||||
}
|
||||
|
||||
let sourceKey;
|
||||
try {
|
||||
sourceKey = resolvePhotoStorageKey(event, photo);
|
||||
logger.info(`Ensuring hero image for photo ${photo.id} from key: ${sourceKey}`);
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) ? e.message : String(e);
|
||||
logger.error(`Failed to resolve original key for hero image (photo ${photo.id}): ${msg}`);
|
||||
return null;
|
||||
}
|
||||
if (!sourceKey) {
|
||||
// Reference-mode event holding a row with no source_origin: the mode falls
|
||||
// back to the event's and resolvePhotoStorageKey returns null. Honour the
|
||||
// null-on-failure contract rather than feeding it to withLocalCopy.
|
||||
logger.warn(`No managed storage key for hero image (photo ${photo.id}); skipping generation`);
|
||||
return null;
|
||||
}
|
||||
|
||||
newHeroPath = await withLocalCopy(sourceKey, async (localPath) => {
|
||||
const proc = await withProcessableImage(localPath, sourceKey);
|
||||
try {
|
||||
return await generateHeroImage(proc.path, { regenerate: true, outputBasename: proc.outputBasename });
|
||||
|
||||
@@ -6,7 +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 { lightboxImageUrl } from './imageTiers';
|
||||
import { feedbackService, type ColorLabel, type KeybindMode } from '../../services/feedback.service';
|
||||
import { PhotoColorLabels } from './PhotoColorLabels';
|
||||
import { resolveFeedbackKey, colorShortcutHints } from '../../utils/feedbackKeybinds';
|
||||
@@ -1143,7 +1143,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={previewUrlForViewport(photo.preview_url, photo) || photo.url}
|
||||
src={lightboxImageUrl(photo)}
|
||||
alt={photo.filename}
|
||||
fallbackSrc={photo.thumbnail_url || undefined}
|
||||
className="max-w-full max-h-full object-contain select-none pointer-events-none"
|
||||
@@ -1168,7 +1168,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
<AuthenticatedImage
|
||||
// Same preview-prefer-with-fallback logic as the
|
||||
// off-screen tile above (#492).
|
||||
src={previewUrlForViewport(photo.preview_url, photo) || photo.url}
|
||||
src={lightboxImageUrl(photo)}
|
||||
alt={photo.filename}
|
||||
fallbackSrc={photo.thumbnail_url || undefined}
|
||||
className="max-w-full max-h-full object-contain select-none"
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* The lightbox must not display a photo by downloading the original (#1166).
|
||||
*
|
||||
* `preview_url` is only emitted when the admin has flipped
|
||||
* lightbox_preview_enabled, which is off by default — so a stock install fell
|
||||
* through to `url`. A reporter measured 16.5 MB for a photo whose preview is
|
||||
* 345 KB, and the lightbox renders its neighbours too, so one open pulled
|
||||
* three originals.
|
||||
*
|
||||
* `slideshow_url` is the same /preview/:id URL and has been emitted
|
||||
* unconditionally for images since #1015. Preferring it is what fixes existing
|
||||
* installs; the tests below pin that, and pin the two cases that must still
|
||||
* reach `url`.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
import { lightboxImageUrl } from '../imageTiers';
|
||||
|
||||
const realWidth = window.innerWidth;
|
||||
const realDpr = window.devicePixelRatio;
|
||||
|
||||
function setViewport(width: number, dpr: number, 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: undefined, configurable: true });
|
||||
}
|
||||
|
||||
// Desktop, so the top tier is chosen and no ?w= is appended — keeps these
|
||||
// assertions about URL SELECTION rather than tier maths (pinned separately in
|
||||
// imageTiers.test.ts).
|
||||
afterEach(() => setViewport(realWidth, realDpr));
|
||||
|
||||
const BIG = { width: 4160, height: 6240 };
|
||||
|
||||
describe('lightboxImageUrl (#1166)', () => {
|
||||
it('uses the preview tier when the admin opted in', () => {
|
||||
setViewport(1440, 2, 900);
|
||||
expect(lightboxImageUrl({
|
||||
url: '/api/gallery/g/photo/47',
|
||||
preview_url: '/api/gallery/g/preview/47',
|
||||
slideshow_url: '/api/gallery/g/preview/47',
|
||||
...BIG,
|
||||
})).toBe('/api/gallery/g/preview/47');
|
||||
});
|
||||
|
||||
it('uses the preview tier when they did NOT — the reported install', () => {
|
||||
// The regression, exactly as filed: preview_url null, slideshow_url set.
|
||||
setViewport(1440, 2, 900);
|
||||
expect(lightboxImageUrl({
|
||||
url: '/api/gallery/g/photo/47',
|
||||
preview_url: null,
|
||||
slideshow_url: '/api/gallery/g/preview/47',
|
||||
...BIG,
|
||||
})).toBe('/api/gallery/g/preview/47');
|
||||
});
|
||||
|
||||
it('never serves the original while a derivative exists', () => {
|
||||
setViewport(1440, 2, 900);
|
||||
for (const preview_url of [null, undefined, '', '/api/gallery/g/preview/47']) {
|
||||
expect(lightboxImageUrl({
|
||||
url: '/api/gallery/g/photo/47',
|
||||
preview_url,
|
||||
slideshow_url: '/api/gallery/g/preview/47',
|
||||
...BIG,
|
||||
})).not.toContain('/photo/');
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the original when neither derivative exists', () => {
|
||||
// Videos: the server emits null for both, and the player needs the real
|
||||
// source.
|
||||
expect(lightboxImageUrl({
|
||||
url: '/api/gallery/g/photo/47',
|
||||
preview_url: null,
|
||||
slideshow_url: null,
|
||||
})).toBe('/api/gallery/g/photo/47');
|
||||
});
|
||||
|
||||
it('carries the watermark query through', () => {
|
||||
// preview_url and slideshow_url are built from the same string server-side
|
||||
// (gallery.js), so the wm parameter is on whichever one is used — losing it
|
||||
// would serve an unwatermarked frame to a gallery that asked for one.
|
||||
setViewport(1440, 2, 900);
|
||||
expect(lightboxImageUrl({
|
||||
url: '/api/gallery/g/photo/47?wm=3',
|
||||
preview_url: null,
|
||||
slideshow_url: '/api/gallery/g/preview/47?wm=3',
|
||||
...BIG,
|
||||
})).toBe('/api/gallery/g/preview/47?wm=3');
|
||||
});
|
||||
|
||||
it.each(['image/gif', 'image/apng', 'image/png'])('keeps the original for %s, which the preview tier would flatten', (mime) => {
|
||||
// generatePreviewImage always encodes JPEG, so routing an animated source
|
||||
// through it replaces the animation with its first frame — behaviour the
|
||||
// toggle-off default never had.
|
||||
setViewport(1440, 2, 900);
|
||||
expect(lightboxImageUrl({
|
||||
url: '/api/gallery/g/photo/47',
|
||||
preview_url: null,
|
||||
slideshow_url: '/api/gallery/g/preview/47',
|
||||
mime_type: mime,
|
||||
...BIG,
|
||||
})).toBe('/api/gallery/g/photo/47');
|
||||
});
|
||||
|
||||
it('catches a PNG that migration 039 mislabelled as image/jpeg', () => {
|
||||
// 039 backfilled every pre-existing photo's mime_type to image/jpeg, and
|
||||
// the external-media importer inserts rows with none at all — so trusting
|
||||
// MIME alone lets exactly the transparent photos through.
|
||||
setViewport(1440, 2, 900);
|
||||
expect(lightboxImageUrl({
|
||||
url: '/api/gallery/g/photo/47',
|
||||
preview_url: null,
|
||||
slideshow_url: '/api/gallery/g/preview/47',
|
||||
mime_type: 'image/jpeg',
|
||||
filename: 'logo-with-alpha.png',
|
||||
...BIG,
|
||||
})).toBe('/api/gallery/g/photo/47');
|
||||
});
|
||||
|
||||
it('catches one with no mime_type at all, as external imports write them', () => {
|
||||
setViewport(1440, 2, 900);
|
||||
expect(lightboxImageUrl({
|
||||
url: '/api/gallery/g/photo/47',
|
||||
preview_url: null,
|
||||
slideshow_url: '/api/gallery/g/preview/47',
|
||||
filename: 'animation.gif',
|
||||
...BIG,
|
||||
})).toBe('/api/gallery/g/photo/47');
|
||||
});
|
||||
|
||||
it('still uses the preview tier for ordinary still formats', () => {
|
||||
setViewport(1440, 2, 900);
|
||||
for (const mime_type of ['image/jpeg', 'image/webp', undefined]) {
|
||||
expect(lightboxImageUrl({
|
||||
url: '/api/gallery/g/photo/47',
|
||||
preview_url: null,
|
||||
slideshow_url: '/api/gallery/g/preview/47',
|
||||
mime_type,
|
||||
...BIG,
|
||||
})).toBe('/api/gallery/g/preview/47');
|
||||
}
|
||||
});
|
||||
|
||||
it('still sizes the fallback tier for the device', () => {
|
||||
// The #1095 behaviour has to survive the new fallback: a phone must not
|
||||
// pull the 1920 rendition just because the URL came from slideshow_url.
|
||||
setViewport(390, 3);
|
||||
// A tall portrait is bound by height, so a DPR-3 phone genuinely needs the
|
||||
// top tier — which is served without ?w=, keeping those URLs byte-identical
|
||||
// to the ones already in browser and CDN caches.
|
||||
expect(lightboxImageUrl({
|
||||
url: '/api/gallery/g/photo/47',
|
||||
preview_url: null,
|
||||
slideshow_url: '/api/gallery/g/preview/47',
|
||||
...BIG,
|
||||
})).toBe('/api/gallery/g/preview/47');
|
||||
|
||||
// Landscape on the same phone needs less, and says so.
|
||||
expect(lightboxImageUrl({
|
||||
url: '/api/gallery/g/photo/47',
|
||||
preview_url: null,
|
||||
slideshow_url: '/api/gallery/g/preview/47',
|
||||
width: 3000,
|
||||
height: 2000,
|
||||
})).toBe('/api/gallery/g/preview/47?w=1280');
|
||||
});
|
||||
});
|
||||
@@ -107,6 +107,66 @@ export function previewUrlForViewport(
|
||||
return withWidth(previewUrl, width);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the lightbox actually puts in an <img> (#1166).
|
||||
*
|
||||
* `preview_url` is only emitted when the admin has flipped
|
||||
* lightbox_preview_enabled, which is off by default — so a stock install fell
|
||||
* straight through to `url`, the untouched original. A reporter measured
|
||||
* 16.5 MB per photo where the preview is 345 KB, and the lightbox renders its
|
||||
* neighbours too, so opening one photo pulled three originals.
|
||||
*
|
||||
* `slideshow_url` is the same /preview/:id URL, watermark query and all, but
|
||||
* emitted unconditionally for images since #1015 — the slideshow has never had
|
||||
* a fallback worth taking. Preferring it here fixes every existing install
|
||||
* without an admin touching a setting.
|
||||
*
|
||||
* `url` stays as the last resort, which is where videos land (both derivative
|
||||
* URLs are null for them) and where an image goes if the server ever stops
|
||||
* emitting either. The preview route generates lazily and redirects to the
|
||||
* original on any failure, so nothing here can show less than it does today.
|
||||
*/
|
||||
export function lightboxImageUrl(photo: {
|
||||
url: string;
|
||||
preview_url?: string | null;
|
||||
slideshow_url?: string | null;
|
||||
mime_type?: string;
|
||||
filename?: string;
|
||||
original_filename?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
}): string {
|
||||
// Animated formats keep the original. generatePreviewImage always encodes
|
||||
// JPEG (imageProcessor.js:668), so routing an animated source through the
|
||||
// preview tier would replace the animation with its first frame — a
|
||||
// regression the toggle-off default never had.
|
||||
//
|
||||
// PNG goes with them, for two reasons that share a cause: generatePreviewImage
|
||||
// also drops ALPHA (a transparent source flattens against a solid
|
||||
// background), and an APNG is normally reported as image/png rather than
|
||||
// image/apng, so the specific check alone would miss the common upload path.
|
||||
// PNG is where transparency is the norm, and it is rare enough in an event
|
||||
// gallery that the bandwidth given up is small.
|
||||
//
|
||||
// MIME is all the frontend has. Animated or alpha WebP reports image/webp
|
||||
// exactly like an ordinary still, and cannot be told apart here — fixing
|
||||
// that needs the backend to report it (Sharp's `metadata.pages`, and
|
||||
// `hasAlpha`) rather than costing every static-WebP gallery the bandwidth
|
||||
// fix on a guess. That is the known remaining gap.
|
||||
// Checked against the FILENAME as well as the MIME, because mime_type is not
|
||||
// trustworthy here: migration 039 backfilled every pre-existing photo as
|
||||
// image/jpeg regardless of what it was, and the external-media importer
|
||||
// inserts rows without a mime_type at all. A mislabelled PNG would otherwise
|
||||
// sail past this and come back flattened.
|
||||
const ORIGINAL_ONLY = ['image/gif', 'image/apng', 'image/png'];
|
||||
const ORIGINAL_ONLY_EXT = /\.(gif|apng|png)$/i;
|
||||
const name = photo.original_filename || photo.filename || '';
|
||||
if ((photo.mime_type && ORIGINAL_ONLY.includes(photo.mime_type)) || ORIGINAL_ONLY_EXT.test(name)) {
|
||||
return photo.url;
|
||||
}
|
||||
return previewUrlForViewport(photo.preview_url || photo.slideshow_url, photo) || photo.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* An aspect-preserved rendition for a face avatar (#1096).
|
||||
*
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useDownloadPhoto } from '../../../hooks/useGallery';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import './GalleryPremiumLayout.css';
|
||||
import { lightboxImageUrl } from '../imageTiers';
|
||||
|
||||
interface PhotoCardProps {
|
||||
photo: Photo;
|
||||
@@ -318,7 +319,17 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
// when the admin has flipped the original-filenames toggle (#508).
|
||||
const slides = useMemo(() => {
|
||||
return filteredPhotos.map(photo => ({
|
||||
src: photo.url,
|
||||
// Display source, not the original (#1166). This layout returns early
|
||||
// from PhotoGridWithLayouts and never renders PhotoLightbox, so it needs
|
||||
// its own call — without it a premium gallery keeps pulling
|
||||
// multi-megabyte originals to show a photo on screen. `download` below
|
||||
// deliberately stays on photo.url: what a guest saves must be the full
|
||||
// original.
|
||||
src: lightboxImageUrl(photo),
|
||||
// The download handler used to recover the photo by matching slide.src
|
||||
// against photo.url. src is a derivative now, so that lookup would find
|
||||
// nothing and Download would silently do nothing (#1166 review).
|
||||
photoId: photo.id,
|
||||
alt: photo.filename,
|
||||
width: photo.width || 1200,
|
||||
height: photo.height || 800,
|
||||
@@ -438,10 +449,15 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
}
|
||||
}, [selectedPhotos, slug, t, downloadChoices, onPickResolution]);
|
||||
|
||||
const handleDownloadFromLightbox = useCallback((slide: { src?: string }) => {
|
||||
const handleDownloadFromLightbox = useCallback((slide: { src?: string; photoId?: number }) => {
|
||||
if (!allowDownloads || !slide.src) return;
|
||||
|
||||
const photo = filteredPhotos.find(p => p.url === slide.src);
|
||||
// By id, carried on the slide. Matching on src broke the moment the slide
|
||||
// stopped being the original — and what Download hands over must stay the
|
||||
// original regardless of what is rendered.
|
||||
const photo = slide.photoId != null
|
||||
? filteredPhotos.find(p => p.id === slide.photoId)
|
||||
: filteredPhotos.find(p => p.url === slide.src);
|
||||
if (photo) {
|
||||
analyticsService.trackDownload(photo.id, slug, false);
|
||||
downloadPhotoMutation.mutate({
|
||||
|
||||
@@ -39,9 +39,14 @@ export const StoryHero: React.FC<StoryHeroProps> = ({
|
||||
<div className="story-hero">
|
||||
{/* Background */}
|
||||
<div className="story-hero-bg">
|
||||
{photo && (photo.url || photo.thumbnail_url) ? (
|
||||
{photo && (photo.hero_url || photo.url || photo.thumbnail_url) ? (
|
||||
<AuthenticatedImage
|
||||
src={photo.url || photo.thumbnail_url || ''}
|
||||
// hero_url, which is what it is for (#1166): a 1920x1080 cover crop,
|
||||
// and this is a full-bleed object-cover background. It rendered
|
||||
// photo.url — a full original on the critical path for first paint
|
||||
// of every Story gallery. Emitted unconditionally for every photo
|
||||
// (gallery.js:1139), so the fallbacks below are belt-and-braces.
|
||||
src={photo.hero_url || photo.url || photo.thumbnail_url || ''}
|
||||
alt="Hero"
|
||||
className="w-full h-full object-cover"
|
||||
isGallery={true}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { Heart } from 'lucide-react';
|
||||
import { AuthenticatedImage } from '../../../common';
|
||||
import { ColorLabelBadge } from '../../ColorLabelBadge';
|
||||
import type { Photo } from '../../../../types';
|
||||
import { lightboxImageUrl } from '../../imageTiers';
|
||||
|
||||
interface StoryPhotoCardProps {
|
||||
photo: Photo;
|
||||
@@ -38,8 +39,24 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
|
||||
void _galleryId;
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
// Don't fetch until the card is near the viewport (#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 means a gallery with cold previews would Sharp-decode
|
||||
// every original in one burst.
|
||||
//
|
||||
// `once` so a card that has loaded never unloads on scroll-away, and the
|
||||
// same 200px margin the entrance animation uses so the image is already in
|
||||
// flight by the time the card animates in.
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const isNearViewport = useInView(cardRef, { once: true, margin: '200px' });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={cardRef}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
@@ -48,7 +65,10 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
|
||||
>
|
||||
<a
|
||||
href={photo.url}
|
||||
data-pswp-src={photo.url}
|
||||
// PhotoSwipe's full-size source (#1166). The preview tier, like every
|
||||
// other lightbox surface — the original is what Download hands out,
|
||||
// not what gets rendered on screen.
|
||||
data-pswp-src={lightboxImageUrl(photo)}
|
||||
data-pswp-width={photo.width || 1200}
|
||||
data-pswp-height={photo.height || 800}
|
||||
data-photo-id={photo.id}
|
||||
@@ -60,8 +80,24 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
|
||||
}}
|
||||
className="block w-full h-full"
|
||||
>
|
||||
{/* The placeholder keeps the card's box while the image is still
|
||||
out of range, so nothing reflows when it arrives. */}
|
||||
{!isNearViewport ? (
|
||||
<div className="w-full h-full bg-neutral-200 dark:bg-neutral-800" aria-hidden="true" />
|
||||
) : (
|
||||
<AuthenticatedImage
|
||||
src={photo.url}
|
||||
// A card tile, and it used to render the full ORIGINAL at
|
||||
// object-cover — the one place where the reporter's "hundreds of
|
||||
// megabytes for a gallery" was literally true (#1166).
|
||||
//
|
||||
// The preview tier rather than the thumbnail, deliberately.
|
||||
// thumbnail_fit is seeded to 'cover' on every install, so thumbnails
|
||||
// are square centre-crops; these cards are not square (400x500 in
|
||||
// the carousel, fixed-height in the desktop grid), so a thumbnail
|
||||
// would be cropped a second time by object-cover and reframe every
|
||||
// photo. Previews use fit:'inside' and are the whole frame, so the
|
||||
// card looks exactly as it did while no longer pulling an original.
|
||||
src={lightboxImageUrl(photo)}
|
||||
alt={photo.filename}
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
className={`w-full h-full object-cover transition-all duration-700 ease-out will-change-transform ${
|
||||
@@ -77,6 +113,7 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* Colour label (#1044) — same badge every layout uses. */}
|
||||
|
||||
@@ -288,18 +288,19 @@ export const ThumbnailsTab: React.FC = () => {
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{/* Lightbox preview tier (#492). Independent opt-in from the
|
||||
thumbnail size/quality settings above — costs disk but
|
||||
dramatically speeds up lightbox open on mobile / slow
|
||||
connections by serving an aspect-preserved ~1920px JPEG
|
||||
instead of the multi-megabyte original. */}
|
||||
{/* Lightbox preview tier (#492). The toggle no longer decides whether
|
||||
the lightbox uses previews — since #1166 it always does, falling back
|
||||
to slideshow_url, which the server emits for every image. Saving it
|
||||
does not itself generate anything, so the copy does not claim to:
|
||||
what it does is unlock the regenerate button below and keep
|
||||
preview_url emitted. */}
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
|
||||
<Image className="w-5 h-5 text-primary-600" />
|
||||
{t('settings.thumbnails.lightboxTitle', 'Lightbox Preview Tier')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('settings.thumbnails.lightboxHelp', 'When enabled, the lightbox loads an aspect-preserved ~1920px JPEG (typically 200–500 KB) instead of the full original (often 5–12 MB). Originals are still served when guests click Download. Costs roughly one extra preview file per photo on disk; previews are generated lazily on first open and stored in /previews.')}
|
||||
{t('settings.thumbnails.lightboxHelp', 'The lightbox shows an aspect-preserved ~1920px JPEG (typically 200–500 KB) rather than the full original (often 5–12 MB). Originals are still served when guests click Download. Previews cost roughly one extra file per photo on disk and are stored in /previews.')}
|
||||
</p>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer mb-4">
|
||||
@@ -311,10 +312,10 @@ export const ThumbnailsTab: React.FC = () => {
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('settings.thumbnails.lightboxToggle', 'Use medium-resolution previews in the lightbox')}
|
||||
{t('settings.thumbnails.lightboxToggle', 'Enable eager preview generation')}
|
||||
</span>
|
||||
<span className="block text-xs text-neutral-600 dark:text-neutral-400 mt-0.5">
|
||||
{t('settings.thumbnails.lightboxToggleHelp', 'Off by default. Flip on after deciding the perceived-perf win is worth the extra disk usage.')}
|
||||
{t('settings.thumbnails.lightboxToggleHelp', 'Off by default: each preview is built the first time a guest opens that photo. Turning this on unlocks the button below, which builds them all up front.')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -1746,9 +1746,9 @@
|
||||
"regenerateStarted": "Neugenerierung der Vorschaubilder gestartet",
|
||||
"regenerateError": "Neugenerierung der Vorschaubilder konnte nicht gestartet werden",
|
||||
"lightboxTitle": "Lightbox-Vorschau-Stufe",
|
||||
"lightboxHelp": "Wenn aktiviert, lädt die Lightbox ein seitenverhältnis-erhaltendes JPEG mit ~1920 px (typischerweise 200–500 KB) statt des vollen Originals (oft 5–12 MB). Beim Download durch Gäste wird weiterhin das Original ausgeliefert. Kostet pro Foto eine zusätzliche Vorschaudatei auf der Festplatte; Vorschauen werden beim ersten Öffnen erzeugt und unter /previews gespeichert.",
|
||||
"lightboxToggle": "Mittelauflösende Vorschauen in der Lightbox verwenden",
|
||||
"lightboxToggleHelp": "Standardmäßig deaktiviert. Aktivieren, sobald der gefühlte Geschwindigkeitsgewinn den zusätzlichen Speicherbedarf rechtfertigt.",
|
||||
"lightboxHelp": "Die Lightbox zeigt ein seitenverhältnis-erhaltendes JPEG mit ~1920 px (typischerweise 200–500 KB) statt des vollen Originals (oft 5–12 MB). Beim Download durch Gäste wird weiterhin das Original ausgeliefert. Vorschauen kosten pro Foto etwa eine zusätzliche Datei auf der Festplatte und liegen unter /previews.",
|
||||
"lightboxToggle": "Vorab-Erzeugung von Vorschauen aktivieren",
|
||||
"lightboxToggleHelp": "Standardmäßig deaktiviert: Jede Vorschau entsteht, sobald ein Gast das Foto zum ersten Mal öffnet. Aktivieren schaltet die Schaltfläche unten frei, die alle im Voraus erzeugt.",
|
||||
"regeneratePreviewsButton": "Alle Vorschauen neu generieren",
|
||||
"previewsRegenerateStarted": "Neugenerierung der Lightbox-Vorschauen gestartet",
|
||||
"previewsRegenerateError": "Neugenerierung der Vorschauen konnte nicht gestartet werden",
|
||||
|
||||
@@ -1354,9 +1354,9 @@
|
||||
"regenerateStarted": "Thumbnail regeneration started",
|
||||
"regenerateError": "Failed to start thumbnail regeneration",
|
||||
"lightboxTitle": "Lightbox Preview Tier",
|
||||
"lightboxHelp": "When enabled, the lightbox loads an aspect-preserved ~1920px JPEG (typically 200–500 KB) instead of the full original (often 5–12 MB). Originals are still served when guests click Download. Costs roughly one extra preview file per photo on disk; previews are generated lazily on first open and stored in /previews.",
|
||||
"lightboxToggle": "Use medium-resolution previews in the lightbox",
|
||||
"lightboxToggleHelp": "Off by default. Flip on after deciding the perceived-perf win is worth the extra disk usage.",
|
||||
"lightboxHelp": "The lightbox shows an aspect-preserved ~1920px JPEG (typically 200–500 KB) rather than the full original (often 5–12 MB). Originals are still served when guests click Download. Previews cost roughly one extra file per photo on disk and are stored in /previews.",
|
||||
"lightboxToggle": "Enable eager preview generation",
|
||||
"lightboxToggleHelp": "Off by default: each preview is built the first time a guest opens that photo. Turning this on unlocks the button below, which builds them all up front.",
|
||||
"regeneratePreviewsButton": "Regenerate All Previews",
|
||||
"previewsRegenerateStarted": "Lightbox preview regeneration started",
|
||||
"previewsRegenerateError": "Failed to start preview regeneration",
|
||||
|
||||
@@ -983,9 +983,9 @@
|
||||
"regenerateStarted": "Régénération des miniatures démarrée",
|
||||
"regenerateError": "Échec du démarrage de la régénération des miniatures",
|
||||
"lightboxTitle": "Niveau d'aperçu de la visionneuse",
|
||||
"lightboxHelp": "Lorsqu'il est activé, la visionneuse charge un JPEG de ~1920px préservant les proportions (généralement 200–500 Ko) au lieu de l'original complet (souvent 5–12 Mo). Les originaux sont toujours servis lorsque les invités cliquent sur Télécharger. Coûte environ un fichier d'aperçu supplémentaire par photo sur le disque ; les aperçus sont générés paresseusement lors de la première ouverture et stockés dans /previews.",
|
||||
"lightboxToggle": "Utiliser des aperçus de résolution moyenne dans la visionneuse",
|
||||
"lightboxToggleHelp": "Désactivé par défaut. Activez après avoir décidé que le gain de performance perçu vaut l'utilisation supplémentaire de disque.",
|
||||
"lightboxHelp": "La visionneuse affiche un JPEG d'environ 1920 px préservant les proportions (généralement 200–500 Ko) plutôt que l'original complet (souvent 5–12 Mo). Les originaux sont toujours servis lorsque les invités cliquent sur Télécharger. Les aperçus coûtent environ un fichier supplémentaire par photo sur le disque et sont stockés dans /previews.",
|
||||
"lightboxToggle": "Activer la génération anticipée des aperçus",
|
||||
"lightboxToggleHelp": "Désactivé par défaut : chaque aperçu est créé la première fois qu'un invité ouvre la photo. L'activer débloque le bouton ci-dessous, qui les crée tous à l'avance.",
|
||||
"regeneratePreviewsButton": "Régénérer tous les aperçus",
|
||||
"previewsRegenerateStarted": "Régénération des aperçus de la visionneuse démarrée",
|
||||
"previewsRegenerateError": "Échec du démarrage de la régénération des aperçus",
|
||||
|
||||
@@ -983,9 +983,9 @@
|
||||
"regenerateStarted": "Ponovno ustvarjanje sličic se je začelo",
|
||||
"regenerateError": "Ponovnega ustvarjanja sličic ni bilo mogoče zagnati",
|
||||
"lightboxTitle": "Raven predogleda v lightboxu",
|
||||
"lightboxHelp": "Ko je omogočeno, lightbox namesto polnega originala (pogosto 5–12 MB) naloži JPEG predogled z ohranjenim razmerjem okoli 1920 px (običajno 200–500 KB). Originali so še vedno uporabljeni, ko gost klikne Prenesi. Na disku to pomeni približno eno dodatno predogledno datoteko na fotografijo; predogledi se ustvarijo po potrebi ob prvem odpiranju in shranijo v /previews.",
|
||||
"lightboxToggle": "Uporabi predoglede srednje ločljivosti v lightboxu",
|
||||
"lightboxToggleHelp": "Privzeto izklopljeno. Vklopite, ko presodite, da je boljša zaznana hitrost vredna dodatne porabe diska.",
|
||||
"lightboxHelp": "Lightbox prikaže JPEG z ohranjenim razmerjem okoli 1920 px (običajno 200–500 KB) namesto polnega originala (pogosto 5–12 MB). Originali so še vedno uporabljeni, ko gost klikne Prenesi. Predogledi na disku pomenijo približno eno dodatno datoteko na fotografijo in so shranjeni v /previews.",
|
||||
"lightboxToggle": "Omogoči vnaprejšnje ustvarjanje predogledov",
|
||||
"lightboxToggleHelp": "Privzeto izklopljeno: vsak predogled nastane, ko gost fotografijo prvič odpre. Vklop odklene spodnji gumb, ki jih ustvari vse vnaprej.",
|
||||
"regeneratePreviewsButton": "Ponovno ustvari vse predoglede",
|
||||
"previewsRegenerateStarted": "Ponovno ustvarjanje predogledov lightbox se je začelo",
|
||||
"previewsRegenerateError": "Ponovnega ustvarjanja predogledov ni bilo mogoče zagnati",
|
||||
|
||||
@@ -155,13 +155,16 @@ export interface Photo {
|
||||
hero_url?: string; // Hero-optimized image URL (1920x1080) for full-width hero sections
|
||||
// Lightbox preview URL (#492). Set only when the admin has flipped
|
||||
// lightbox_preview_enabled in Settings → Thumbnails. Aspect-preserved
|
||||
// ≤1920px JPEG; the lightbox prefers it over `url` for image photos
|
||||
// and falls back to `url` when null (off, video, or not yet generated).
|
||||
// ≤1920px JPEG.
|
||||
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.
|
||||
//
|
||||
// The lightbox takes it as its second choice for the same reason (#1166):
|
||||
// the same /preview/:id URL, so an install that never flipped the toggle
|
||||
// stops serving multi-megabyte originals to display a photo on screen.
|
||||
slideshow_url?: string | null;
|
||||
secure_url_template?: string;
|
||||
download_url_template?: string;
|
||||
|
||||
Reference in New Issue
Block a user