* fix(gallery): stop the lightbox loading originals to display a photo (#1166) Stable twin of #1169. 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. Preferring it fixes every existing install with no migration and no admin action. Two other surfaces bypass PhotoLightbox entirely and had the same bug: - premium galleries build their own slides with `src: photo.url`. Fixing that also required carrying the photo id on the slide, because the download handler recovered the photo by matching slide.src against photo.url — a derivative src would have made Download a silent no-op. - the Story layout rendered the full original as its GRID TILE, at object-cover in a small card, and its hero rendered one as a full-bleed background when hero_url exists for exactly that. Cards now use the preview tier (not the thumbnail: thumbnail_fit is seeded to 'cover', so a thumbnail would be cropped a second time and reframe every photo) and only load once within 200px of the viewport, since every card mounts at page load. GIF, APNG and PNG keep the original: generatePreviewImage encodes JPEG, which has neither a second frame nor an alpha channel. The backend fix that removes this list is the next commit in this stack. Divergence from the main twin: no responsive `?w=` tiers. #1095 is main-only, so `lightboxImageUrl` here selects a URL and nothing more. It lives in `imageTiers.ts` under the same path main uses, so that backporting #1095 later merges into this file rather than landing beside it. Verified on this branch: 8 new tests; frontend suite 21 files / 113 tests, tsc clean. * fix(gallery): make the Story hero fix actually work on external galleries (#1166) External review. Same two fixes as the main twin. 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. Needed one extra piece here that main already had: generateHeroImage on this branch ignores outputBasename and always derives the key from the source basename, so two events referencing the same NAS filename would clobber each other's hero. It now honours the option, matching generateThumbnail and generatePreviewImage. The format bypass trusted mime_type, which is not trustworthy: 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) Same fix as the main twin: external_relpath has been resolved from EXTERNAL_MEDIA_ROOT rather than from event.external_path since #1163 landed, and this fixture still carried the base-relative form, so the two tests stopped resolving the moment that stack merged. Production was never affected. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
58ccecc304
commit
75facb4d67
@@ -11,6 +11,7 @@ import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { VideoPlayer } from './VideoPlayer';
|
||||
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
|
||||
import { useFeedbackLimitModal } from '../../hooks/useFeedbackLimitModal';
|
||||
import { lightboxImageUrl } from './imageTiers';
|
||||
|
||||
interface PhotoLightboxProps {
|
||||
photos: Photo[];
|
||||
@@ -838,12 +839,10 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
/>
|
||||
) : (
|
||||
<AuthenticatedImage
|
||||
// Prefer the lightbox preview tier when the admin
|
||||
// opted in (#492). Falls back to `url` (the
|
||||
// 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}
|
||||
// The preview tier, falling back to slideshow_url when the
|
||||
// admin never flipped lightbox_preview_enabled (#1166) —
|
||||
// otherwise a stock install renders the untouched original.
|
||||
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"
|
||||
@@ -867,9 +866,8 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
onClick={handleImageClick}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
// Same preview-prefer-with-fallback logic as the
|
||||
// off-screen tile above (#492).
|
||||
src={photo.preview_url || photo.url}
|
||||
// Same source selection as the off-screen tile above (#1166).
|
||||
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,97 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* No viewport-tier cases here: the responsive `?w=` machinery (#1095) is
|
||||
* main-only, so this branch uses the URLs exactly as the server emits them.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { lightboxImageUrl } from '../imageTiers';
|
||||
|
||||
const PHOTO = {
|
||||
url: '/api/gallery/g/photo/47',
|
||||
preview_url: null as string | null,
|
||||
slideshow_url: '/api/gallery/g/preview/47' as string | null,
|
||||
};
|
||||
|
||||
describe('lightboxImageUrl (#1166)', () => {
|
||||
it('uses the preview tier when the admin opted in', () => {
|
||||
expect(lightboxImageUrl({ ...PHOTO, preview_url: '/api/gallery/g/preview/47' }))
|
||||
.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.
|
||||
expect(lightboxImageUrl(PHOTO)).toBe('/api/gallery/g/preview/47');
|
||||
});
|
||||
|
||||
it('never serves the original while a derivative exists', () => {
|
||||
for (const preview_url of [null, undefined, '', '/api/gallery/g/preview/47']) {
|
||||
expect(lightboxImageUrl({ ...PHOTO, preview_url })).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({ ...PHOTO, 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,
|
||||
// so the wm parameter is on whichever one is used — losing it would serve
|
||||
// an unwatermarked frame to a gallery that asked for one.
|
||||
expect(lightboxImageUrl({
|
||||
url: '/api/gallery/g/photo/47?wm=3',
|
||||
preview_url: null,
|
||||
slideshow_url: '/api/gallery/g/preview/47?wm=3',
|
||||
})).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_type) => {
|
||||
// generatePreviewImage encodes JPEG: no second frame, no alpha channel.
|
||||
expect(lightboxImageUrl({ ...PHOTO, mime_type })).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.
|
||||
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',
|
||||
})).toBe('/api/gallery/g/photo/47');
|
||||
});
|
||||
|
||||
it('catches one with no mime_type at all, as external imports write them', () => {
|
||||
expect(lightboxImageUrl({
|
||||
url: '/api/gallery/g/photo/47',
|
||||
preview_url: null,
|
||||
slideshow_url: '/api/gallery/g/preview/47',
|
||||
filename: 'animation.gif',
|
||||
})).toBe('/api/gallery/g/photo/47');
|
||||
});
|
||||
|
||||
it('still uses the preview tier for ordinary still formats', () => {
|
||||
for (const mime_type of ['image/jpeg', 'image/webp', undefined]) {
|
||||
expect(lightboxImageUrl({ ...PHOTO, mime_type })).toBe('/api/gallery/g/preview/47');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Which rendition a surface should display.
|
||||
*
|
||||
* On `main` this file also carries the responsive tier machinery (#1095) —
|
||||
* `previewUrlForViewport`, `thumbnailUrlForTile`, the width tables. None of
|
||||
* that is on this branch, so URLs here are used as the server emits them. The
|
||||
* filename matches main deliberately, so that when #1095 is backported it
|
||||
* merges into this file rather than landing beside it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 (#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;
|
||||
}): string {
|
||||
// Animated and transparent formats keep the original. generatePreviewImage
|
||||
// encodes JPEG, which has neither a second frame nor an alpha channel, so
|
||||
// routing these through the preview tier would replace an animation with its
|
||||
// first frame and flatten transparency onto a solid background — a
|
||||
// regression the toggle-off default never had.
|
||||
//
|
||||
// PNG is in the list because that is where transparency is the norm, and
|
||||
// because an APNG is normally reported as image/png rather than image/apng.
|
||||
// Animated or alpha WebP declares image/webp exactly like an ordinary still
|
||||
// and cannot be told apart from MIME.
|
||||
//
|
||||
// The proper fix is backend-side, encoding WebP for alpha or multi-page
|
||||
// sources; when that lands this list goes away entirely.
|
||||
// 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 photo.preview_url || photo.slideshow_url || photo.url;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { useDownloadPhoto } from '../../../hooks/useGallery';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import './GalleryPremiumLayout.css';
|
||||
import { lightboxImageUrl } from '../imageTiers';
|
||||
|
||||
interface PhotoCardProps {
|
||||
photo: Photo;
|
||||
@@ -256,7 +257,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 recovers the photo by id, because matching
|
||||
// slide.src against photo.url stops working the moment src is a
|
||||
// derivative — Download would silently do nothing.
|
||||
photoId: photo.id,
|
||||
alt: photo.filename,
|
||||
width: photo.width || 1200,
|
||||
height: photo.height || 800,
|
||||
@@ -371,10 +382,15 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
}
|
||||
}, [selectedPhotos, slug, t]);
|
||||
|
||||
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,
|
||||
// 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,8 +1,9 @@
|
||||
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 type { Photo } from '../../../../types';
|
||||
import { lightboxImageUrl } from '../../imageTiers';
|
||||
|
||||
interface StoryPhotoCardProps {
|
||||
photo: Photo;
|
||||
@@ -37,8 +38,22 @@ 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.
|
||||
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' }}
|
||||
@@ -47,7 +62,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}
|
||||
@@ -59,8 +77,22 @@ 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, so a
|
||||
// thumbnail would be cropped a second time by object-cover and
|
||||
// reframe every photo. Previews are the whole frame.
|
||||
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 ${
|
||||
@@ -76,6 +108,7 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* Overlay */}
|
||||
|
||||
@@ -299,7 +299,7 @@ export const ThumbnailsTab: React.FC = () => {
|
||||
{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 +311,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>
|
||||
|
||||
@@ -1544,9 +1544,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": "Lightbox-Vorschauen vorab erzeugen",
|
||||
"lightboxToggleHelp": "Standardmäßig deaktiviert: Eine 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",
|
||||
|
||||
@@ -1152,9 +1152,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",
|
||||
|
||||
@@ -953,9 +953,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",
|
||||
|
||||
@@ -953,9 +953,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",
|
||||
|
||||
Reference in New Issue
Block a user