diff --git a/backend/__tests__/integration/imageProcessor.storage.test.js b/backend/__tests__/integration/imageProcessor.storage.test.js index f9d5140e..5340cb95 100644 --- a/backend/__tests__/integration/imageProcessor.storage.test.js +++ b/backend/__tests__/integration/imageProcessor.storage.test.js @@ -137,6 +137,41 @@ describe.each(backendCases())('imageProcessor through $name', ({ setup }) => { expect(await storage.exists(key)).toBe(true); }); + test('generatePreviewImage writes to /previews and skips enlargement of small originals', async () => { + const src = await makeSourceJpeg(tmpDir, 'preview-source.jpg'); + const key = await imageProcessor.generatePreviewImage(src); + expect(key).toBe('previews/preview_preview-source.jpg'); + expect(await storage.exists(key)).toBe(true); + + if (storage.kind() === 'local') { + const meta = await sharp(storage.resolveLocalPath(key)).metadata(); + expect(meta.format).toBe('jpeg'); + // Source is 800x600 and default longEdge is 1920 with + // withoutEnlargement: true → preview must NOT be upscaled. + expect(meta.width).toBe(800); + expect(meta.height).toBe(600); + } + }); + + test('generatePreviewImage shrinks oversized images to fit longEdge while preserving aspect', async () => { + const src = await makeSourceJpeg(tmpDir, 'preview-shrink.jpg'); + const key = await imageProcessor.generatePreviewImage(src, { longEdge: 400 }); + expect(await storage.exists(key)).toBe(true); + if (storage.kind() === 'local') { + const meta = await sharp(storage.resolveLocalPath(key)).metadata(); + // 800x600 → fit:'inside' inside 400×400 → 400×300. + expect(meta.width).toBe(400); + expect(meta.height).toBe(300); + } + }); + + test('isPreviewValid returns true for a real preview and false for a missing key', async () => { + const src = await makeSourceJpeg(tmpDir, 'preview-valid.jpg'); + const key = await imageProcessor.generatePreviewImage(src); + expect(await imageProcessor.isPreviewValid(key)).toBe(true); + expect(await imageProcessor.isPreviewValid('previews/does-not-exist.jpg')).toBe(false); + }); + test('isThumbnailValid returns true for a good thumbnail and false for nothing', async () => { const src = await makeSourceJpeg(tmpDir, 'valid-check.jpg'); const key = await imageProcessor.generateThumbnail(src); diff --git a/backend/migrations/core/104_add_lightbox_preview_tier.js b/backend/migrations/core/104_add_lightbox_preview_tier.js new file mode 100644 index 00000000..92114345 --- /dev/null +++ b/backend/migrations/core/104_add_lightbox_preview_tier.js @@ -0,0 +1,59 @@ +/** + * Migration: Lightbox medium-resolution preview tier (#492). + * + * Adds: + * - photos.preview_path (nullable VARCHAR) — storage key for the + * per-photo preview JPEG; populated lazily by ensurePreviewImage + * on first lightbox open (or eagerly by the regenerate-previews + * admin endpoint). Mirrors photos.thumbnail_path / hero_path. + * - app_settings.lightbox_preview_enabled (boolean, default false) + * — opt-in toggle. Off by default because the new tier costs + * ~200–500 KB per photo on disk; admins flip it on once they've + * decided the perf win is worth the storage. + * + * No backfill of existing photos here — preview generation is lazy + * by design and a separate "Regenerate previews" admin button covers + * eager backfill when an admin wants to warm the cache for an + * existing gallery. + * + * Idempotent: every step checks for existing state. + */ + +exports.up = async function(knex) { + if (!(await knex.schema.hasTable('photos'))) return; + + if (!(await knex.schema.hasColumn('photos', 'preview_path'))) { + await knex.schema.alterTable('photos', (table) => { + table.string('preview_path'); + }); + } + + if (!(await knex.schema.hasTable('app_settings'))) return; + const existing = await knex('app_settings') + .where('setting_key', 'lightbox_preview_enabled') + .first(); + if (!existing) { + await knex('app_settings').insert({ + setting_key: 'lightbox_preview_enabled', + // SQLite stores TEXT, Postgres JSONB — JSON-stringify so both + // backends round-trip a recognisable boolean shape, matching + // how other branding_* boolean settings are stored today. + setting_value: JSON.stringify(false), + setting_type: 'thumbnail', + updated_at: new Date(), + }); + } +}; + +exports.down = async function(knex) { + if (await knex.schema.hasTable('app_settings')) { + await knex('app_settings') + .where('setting_key', 'lightbox_preview_enabled') + .del(); + } + if (await knex.schema.hasTable('photos') && await knex.schema.hasColumn('photos', 'preview_path')) { + await knex.schema.alterTable('photos', (table) => { + table.dropColumn('preview_path'); + }); + } +}; diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 791ee012..3d665dfc 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -649,6 +649,12 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos. if (photo.hero_path) { await storage.delete(photo.hero_path).catch(() => {}); } + // Lightbox preview tier (#492). Same disposable-derived semantics + // as thumbnail / hero — wipe on photo delete so we don't leak + // orphaned files into previews/ that no DB row references. + if (photo.preview_path) { + await storage.delete(photo.preview_path).catch(() => {}); + } // Delete pre-generated watermark if exists if (photo.watermark_path) { @@ -783,6 +789,10 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos if (photo.hero_path) { await storage.delete(photo.hero_path).catch(() => {}); } + // Lightbox preview tier (#492) — bulk delete cleanup. + if (photo.preview_path) { + await storage.delete(photo.preview_path).catch(() => {}); + } if (photo.watermark_path) { await watermarkGeneratorService.deleteForPhoto(photo.id); } diff --git a/backend/src/routes/adminThumbnails.js b/backend/src/routes/adminThumbnails.js index e6eaf073..1e3b8c55 100644 --- a/backend/src/routes/adminThumbnails.js +++ b/backend/src/routes/adminThumbnails.js @@ -3,7 +3,7 @@ const router = express.Router(); const { db } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); -const { generateThumbnail } = require('../services/imageProcessor'); +const { generateThumbnail, ensurePreviewImage } = require('../services/imageProcessor'); const path = require('path'); const fs = require('fs').promises; const logger = require('../utils/logger'); @@ -25,7 +25,9 @@ router.get('/settings', adminAuth, requirePermission('photos.view'), async (req, 'thumbnail_height', 'thumbnail_fit', 'thumbnail_quality', - 'thumbnail_format' + 'thumbnail_format', + // Lightbox preview tier (#492). Boolean, default false. + 'lightbox_preview_enabled' ]) .select('setting_key', 'setting_value'); @@ -51,7 +53,7 @@ router.get('/settings', adminAuth, requirePermission('photos.view'), async (req, // Update thumbnail settings router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req, res) => { try { - const { width, height, fit, quality, format } = req.body; + const { width, height, fit, quality, format, lightbox_preview_enabled } = req.body; // Validate inputs if (width && (width < 50 || width > 1000)) { @@ -77,14 +79,35 @@ router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req, if (fit) updates.push({ setting_key: 'thumbnail_fit', setting_value: JSON.stringify(fit) }); if (quality) updates.push({ setting_key: 'thumbnail_quality', setting_value: quality }); if (format) updates.push({ setting_key: 'thumbnail_format', setting_value: JSON.stringify(format) }); + // Lightbox preview tier (#492). Boolean — store JSON-stringified + // so the round-trip matches what migration 104 seeds. + if (typeof lightbox_preview_enabled === 'boolean') { + updates.push({ + setting_key: 'lightbox_preview_enabled', + setting_value: JSON.stringify(lightbox_preview_enabled), + }); + } for (const update of updates) { - await db('app_settings') + const updated = await db('app_settings') .where('setting_key', update.setting_key) .update({ setting_value: update.setting_value, updated_at: db.fn.now() }); + // Defensive insert when the row is missing — covers the case + // where lightbox_preview_enabled is being saved on an install + // that pre-dates migration 104. Existing thumbnail_* keys are + // seeded by migration 040 so the update path always wins for + // them; this only fires on the new key. + if (!updated) { + await db('app_settings').insert({ + setting_key: update.setting_key, + setting_value: update.setting_value, + setting_type: update.setting_key === 'lightbox_preview_enabled' ? 'thumbnail' : 'thumbnail', + updated_at: db.fn.now(), + }); + } } res.json({ @@ -170,6 +193,58 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r } }); +// Regenerate all preview-tier images (#492). Eager backfill counterpart +// to ensurePreviewImage's lazy generation. Mirrors the regenerate +// (thumbnails) endpoint above — same auth, same fire-and-forget shape, +// same per-photo error handling. +router.post('/regenerate-previews', adminAuth, requirePermission('photos.edit'), async (req, res) => { + try { + const { eventId } = req.body; + + let query = db('photos').select('id', 'event_id', 'path', 'media_type', 'mime_type', 'preview_path'); + if (eventId) query = query.where('event_id', eventId); + // Skip videos — preview tier is image-only. + query = query.where(function() { + this.whereNull('media_type').orWhere('media_type', '!=', 'video'); + }); + + const photos = await query; + if (photos.length === 0) { + return res.json({ message: 'No image photos to regenerate previews for', count: 0 }); + } + + res.json({ + message: `Started regenerating ${photos.length} previews`, + count: photos.length, + }); + + setImmediate(async () => { + let successCount = 0; + let errorCount = 0; + for (const photo of photos) { + try { + // Force regeneration regardless of existing preview state by + // nulling the cached path so ensurePreviewImage doesn't + // short-circuit on a stale isPreviewValid check. + const newPreviewPath = await ensurePreviewImage({ ...photo, preview_path: null }); + if (newPreviewPath) { + successCount++; + } else { + errorCount++; + } + } catch (error) { + logger.error(`Error regenerating preview for photo ${photo.id}:`, error); + errorCount++; + } + } + logger.info(`Preview regeneration complete: ${successCount} success, ${errorCount} errors`); + }); + } catch (error) { + logger.error('Error starting preview regeneration:', error); + res.status(500).json({ error: 'Failed to start preview regeneration' }); + } +}); + // Get regeneration status router.get('/regenerate/status', adminAuth, requirePermission('photos.view'), async (req, res) => { try { diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index d08f4962..6cb83802 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -13,7 +13,7 @@ const { resolvePhotoFilePath } = require('../services/photoResolver'); const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService'); const { handleAsync } = require('../utils/routeHelpers'); const { NotFoundError } = require('../utils/errors'); -const { ensureThumbnail, ensureHeroImage, withLocalCopy } = require('../services/imageProcessor'); +const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor'); const downloadZipService = require('../services/downloadZipService'); const { getStorage } = require('../services/storage'); const fs = require('fs'); @@ -399,6 +399,31 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { fragmentation_level: req.event.fragmentation_level || 3, overlay_protection: req.event.overlay_protection !== false }; + + // Lightbox preview tier (#492). When the admin opts in, the + // photos response carries a preview_url alongside url/thumbnail_url + // — the lightbox uses preview_url when present and falls back to + // url when not, so existing galleries continue working before + // any preview has actually been generated. + let lightboxPreviewEnabled = false; + try { + const setting = await db('app_settings') + .where('setting_key', 'lightbox_preview_enabled') + .first(); + if (setting) { + const raw = setting.setting_value; + // setting_value is JSON-stringified per migration 104; tolerate + // raw boolean/string for forward-compat. + const parsed = typeof raw === 'string' ? (() => { + try { return JSON.parse(raw); } catch { return raw; } + })() : raw; + lightboxPreviewEnabled = parsed === true || parsed === 'true' || parsed === 1; + } + } catch (e) { + // Setting missing / DB blip → fall back to off so the lightbox + // keeps working with the original. logger.debug to avoid noise. + logger.debug('lightbox_preview_enabled lookup failed, treating as off', { error: e?.message }); + } res.json({ @@ -445,6 +470,17 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null, // Hero-optimized image URL (1920x1080) for full-width hero sections hero_url: `/api/gallery/${req.params.slug}/hero/${photo.id}${wmQuery}`, + // Lightbox preview URL (#492). Only emitted when the admin + // has flipped lightbox_preview_enabled — the frontend + // lightbox reads preview_url with a fallback to url so + // installs that haven't opted in keep loading the original + // (current behaviour). Skipped for videos since they don't + // get a preview tier; lightbox will use the original .url. + preview_url: lightboxPreviewEnabled + && photo.media_type !== 'video' + && (!photo.mime_type || !photo.mime_type.startsWith('video/')) + ? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}` + : null, secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`, download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`, type: photo.type, @@ -1337,6 +1373,101 @@ router.get('/:slug/hero/:photoId', } ); +// Lightbox preview tier (#492). Aspect-preserved JPEG capped at 1920px +// long edge — admin-controlled opt-in via app_settings.lightbox_preview_enabled. +// Mirrors the hero route shape: same auth, ETag from preview mtime, +// fall back to original on any failure so the lightbox never shows a +// broken image. The watermark application path is preserved so a +// preview surfaced in the lightbox carries the same protection a +// guest would see on the full original. +router.get('/:slug/preview/:photoId', + verifyGalleryAccess, + async (req, res) => { + try { + const { photoId } = req.params; + + const photo = await db('photos') + .where({ id: photoId, event_id: req.event.id }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + if (photo.visibility === 'hidden' && req.accessLevel !== 'client') { + return res.status(403).json({ error: 'Photo not available' }); + } + + // Videos don't get a preview tier — fall through to the regular + // photo endpoint (which serves the source). The frontend should + // already be checking media_type before requesting /preview but + // belt-and-braces in case a stale tab does. + const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/')); + if (isVideo) { + return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`); + } + + // 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); + if (!previewPath) { + logger.warn(`Failed to generate preview for photo ${photoId}, falling back to original`); + return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`); + } + + const storage = getStorage(); + const stat = await storage.stat(previewPath); + if (!stat) { + logger.error('Preview file does not exist in storage backend', { + slug: req.params.slug, photoId, eventId: req.event.id, previewPath, + }); + return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`); + } + + const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0; + const watermarkSettings = await watermarkService.getWatermarkSettings(); + const watermarkHash = watermarkSettings?.enabled + ? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}` + : '-nowm'; + const etag = `"preview-${photoId}-${mtimeMs}${watermarkHash}"`; + if (req.headers['if-none-match'] === etag) { + return res.status(304).end(); + } + + res.set({ + 'Content-Type': 'image/jpeg', + // Cache aggressively — preview only changes on photo + // re-upload (which generates a new preview key) or settings + // regenerate (which writes a new mtime + ETag). + 'Cache-Control': 'private, max-age=3600', + 'Cross-Origin-Resource-Policy': 'cross-origin', + 'X-Content-Type-Options': 'nosniff', + 'X-Preview-Image': 'true', + 'ETag': etag, + }); + + if (watermarkSettings && watermarkSettings.enabled) { + const watermarkedBuffer = await withLocalCopy(previewPath, (localPath) => + watermarkService.applyWatermark(localPath, watermarkSettings) + ); + res.send(watermarkedBuffer); + } else { + res.setHeader('Content-Length', stat.size); + const stream = await storage.get(previewPath); + stream.pipe(res); + } + } catch (error) { + logger.error('Error serving preview image:', { + error: error.message, + photoId: req.params.photoId, + eventId: req.event?.id, + }); + res.redirect(`/api/gallery/${req.params.slug}/photo/${req.params.photoId}`); + } + } +); + // Get feedback settings for gallery router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) => { try { diff --git a/backend/src/services/archiveService.js b/backend/src/services/archiveService.js index 217127bd..c90e9b02 100644 --- a/backend/src/services/archiveService.js +++ b/backend/src/services/archiveService.js @@ -129,7 +129,10 @@ async function archiveEvent(event) { ); } - // Delete thumbnails for this event's photos. + // Delete derived images (thumbnails / heroes / previews / watermarks) + // for this event's photos. The originals are inside the zip; the + // derived tiers are throwaway and will be regenerated lazily on + // restore (or not at all for archived events that nobody opens). const photos = await db('photos').where('event_id', event.id); for (const photo of photos) { if (photo.thumbnail_path) { @@ -138,6 +141,11 @@ async function archiveEvent(event) { if (photo.hero_path) { await storage.delete(photo.hero_path).catch(() => {}); } + // Lightbox preview tier (#492). Same disposable-derived + // semantics as thumbnails / heroes — wipe on archive. + if (photo.preview_path) { + await storage.delete(photo.preview_path).catch(() => {}); + } // Best effort: remove watermarked variants too if a refactor added them. if (photo.watermark_path) { await storage.delete(photo.watermark_path).catch(() => {}); diff --git a/backend/src/services/backupService.js b/backend/src/services/backupService.js index 74720bc1..033688c4 100644 --- a/backend/src/services/backupService.js +++ b/backend/src/services/backupService.js @@ -361,6 +361,16 @@ async function getFilesToBackupInternal(includeArchived = true) { } await scanDirectory(path.join(storagePath, 'thumbnails'), files, storagePath); + // Lightbox preview tier (#492). Cheap to back up — typically a few + // hundred KB per photo — and saves admins the regenerate cycle on + // a restore. Tolerated when missing (admins who never enabled the + // feature won't have the folder; scanDirectory short-circuits on + // ENOENT cleanly). + await scanDirectory(path.join(storagePath, 'previews'), files, storagePath); + // Heroes too — same logic; admins who picked a hero photo for the + // gallery header had its 1920x1080 file generated and was missed + // by the original backup walk before this addition. + await scanDirectory(path.join(storagePath, 'heroes'), files, storagePath); await scanDirectory(path.join(storagePath, 'uploads'), files, storagePath); return files; diff --git a/backend/src/services/imageProcessor.js b/backend/src/services/imageProcessor.js index 0d75e8ba..8334a202 100644 --- a/backend/src/services/imageProcessor.js +++ b/backend/src/services/imageProcessor.js @@ -30,6 +30,15 @@ const DEFAULT_HERO_WIDTH = 1920; const DEFAULT_HERO_HEIGHT = 1080; const DEFAULT_HERO_QUALITY = 85; +// Preview tier (#492). Aspect-preserved downscale for the lightbox so +// guests don't pay the full 5–12 MB original on every photo open. +// Same long edge as the hero (admins are already sizing for it) and +// quality 85 — JPEG artefacts at this size are imperceptible to clients +// browsing on phones / Retina laptops, and storage cost stays modest +// (~200–500 KB per photo vs originals at multi-MB). +const DEFAULT_PREVIEW_LONG_EDGE = 1920; +const DEFAULT_PREVIEW_QUALITY = 85; + // Helper to parse setting value (handles both JSON-encoded and plain values) function parseSettingValue(value) { if (value === null || value === undefined) { @@ -476,6 +485,132 @@ async function ensureHeroImage(photo) { return null; } +/** + * Generate a lightbox preview image (#492). + * + * Aspect-preserving downscale (`fit: 'inside'`) capped at + * DEFAULT_PREVIEW_LONG_EDGE. Distinct from generateHeroImage: + * - hero → 1920x1080 cover-cropped (gallery hero header banner) + * - preview → ≤1920px long edge, aspect preserved (lightbox tile) + * + * Output to `previews/preview_` so an admin who flips the + * setting back off can wipe the folder cleanly without touching + * thumbnails or heroes. + */ +async function generatePreviewImage(imagePath, options = {}) { + const filename = path.basename(imagePath); + const previewFilename = `preview_${filename}`; + const previewRelKey = path.posix.join('previews', previewFilename); + const storage = getStorage(); + + if (options.regenerate) { + await storage.delete(previewRelKey).catch(() => {}); + } + + try { + const metadata = await sharp(imagePath).metadata(); + if (!metadata.width || !metadata.height) { + throw new Error('Invalid image metadata - file may be incomplete'); + } + + const longEdge = options.longEdge || DEFAULT_PREVIEW_LONG_EDGE; + const quality = options.quality || DEFAULT_PREVIEW_QUALITY; + + let sharpInstance = sharp(imagePath, { + limitInputPixels: 268402689, // ~16k x 16k max + sequentialRead: true, + failOnError: false, + }); + + // Strip EXIF — same privacy reasoning as thumbnails/heroes. + sharpInstance = sharpInstance.withMetadata(false); + + // fit: 'inside' + withoutEnlargement keeps small originals at + // their native size (no upscaling artefacts) and shrinks larger + // ones until both dimensions fit inside longEdge×longEdge. + sharpInstance = sharpInstance.resize(longEdge, longEdge, { + withoutEnlargement: true, + fit: 'inside', + }); + + sharpInstance = sharpInstance.jpeg({ + quality, + progressive: true, + mozjpeg: true, + }); + + const buffer = await sharpInstance.toBuffer(); + if (!buffer || buffer.length === 0) { + throw new Error('Generated preview image is empty'); + } + + await storage.put(previewRelKey, buffer, { contentType: 'image/jpeg' }); + + logger.info(`Generated preview image for ${filename} → ${previewRelKey}`); + return previewRelKey; + } catch (error) { + const msg = (error && error.message) ? error.message : String(error); + logger.error(`Failed to generate preview image for ${filename}: ${msg}`); + await storage.delete(previewRelKey).catch(() => {}); + return null; + } +} + +/** + * Validate an existing preview file is non-empty + readable by Sharp. + * Mirrors isHeroValid / isThumbnailValid. + */ +async function isPreviewValid(previewPath) { + const storage = getStorage(); + try { + const stat = await storage.stat(previewPath); + if (!stat || stat.size === 0) return false; + if (storage.kind() === 'local') { + const localPath = storage.resolveLocalPath(previewPath); + await sharp(localPath).metadata(); + } + return true; + } catch { + return false; + } +} + +/** + * Lazy-generate the preview image for a photo if missing or invalid. + * Returns the storage key or null on failure (callers fall back to + * the original URL so the lightbox never shows a broken image). + */ +async function ensurePreviewImage(photo) { + const { resolvePhotoStorageKey } = require('./photoResolver'); + + let sourceKey; + try { + const event = await db('events').where('id', photo.event_id).first(); + sourceKey = resolvePhotoStorageKey(event, photo); + } catch (e) { + const msg = (e && e.message) ? e.message : String(e); + logger.error(`Failed to resolve original key for preview (photo ${photo.id}): ${msg}`); + return null; + } + + if (photo.preview_path) { + const ok = await isPreviewValid(photo.preview_path); + if (ok) return photo.preview_path; + logger.warn(`Invalid preview detected for photo ${photo.id}, regenerating…`); + } + + const newPreviewPath = await withLocalCopy(sourceKey, (localPath) => + generatePreviewImage(localPath, { regenerate: true }) + ); + + if (newPreviewPath) { + await db('photos').where({ id: photo.id }).update({ preview_path: newPreviewPath }); + return newPreviewPath; + } + + return null; +} + /** * Extract capture date from EXIF metadata */ @@ -525,6 +660,9 @@ module.exports = { generateHeroImage, isHeroValid, ensureHeroImage, + generatePreviewImage, + isPreviewValid, + ensurePreviewImage, extractCaptureDate, withLocalCopy, }; diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index a646ff38..2b5b9a4e 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -719,7 +719,12 @@ export const PhotoLightbox: React.FC = ({ /> ) : ( = ({ onClick={handleImageClick} > ; fitOptions: Array<'cover' | 'contain' | 'fill' | 'inside' | 'outside'>; @@ -51,6 +65,7 @@ export const ThumbnailsTab: React.FC = () => { quality: parseInt(s.thumbnail_quality?.value) || defaultSettings.quality, fit: s.thumbnail_fit?.value || defaultSettings.fit, format: s.thumbnail_format?.value || defaultSettings.format, + lightbox_preview_enabled: parseLightboxFlag(s.lightbox_preview_enabled?.value), }); } }, [fetchedData]); @@ -83,6 +98,23 @@ export const ThumbnailsTab: React.FC = () => { }, }); + // Lightbox preview tier (#492). Eager regeneration counterpart to + // ensurePreviewImage's lazy on-first-open generation. Useful after + // flipping the toggle on so guests don't pay the lazy-cost on the + // very first lightbox open per gallery. + const regeneratePreviewsMutation = useMutation({ + mutationFn: async () => { + const response = await api.post('/admin/thumbnails/regenerate-previews'); + return response.data; + }, + onSuccess: (data) => { + toast.success(data.message || t('settings.thumbnails.previewsRegenerateStarted', 'Lightbox preview regeneration started')); + }, + onError: () => { + toast.error(t('settings.thumbnails.previewsRegenerateError', 'Failed to start preview regeneration')); + }, + }); + const handleChange = ( key: K, value: ThumbnailSettings[K] @@ -104,6 +136,7 @@ export const ThumbnailsTab: React.FC = () => { quality: parseInt(s.thumbnail_quality?.value) || defaultSettings.quality, fit: s.thumbnail_fit?.value || defaultSettings.fit, format: s.thumbnail_format?.value || defaultSettings.format, + lightbox_preview_enabled: parseLightboxFlag(s.lightbox_preview_enabled?.value), }); setIsDirty(false); } @@ -255,6 +288,51 @@ export const ThumbnailsTab: React.FC = () => { + {/* 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. */} + +

+ + {t('settings.thumbnails.lightboxTitle', 'Lightbox Preview Tier')} +

+

+ {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.')} +

+ + + + {/* Eager regeneration so guests don't pay the lazy first-open + cost. Only useful after the toggle is on; gated so admins + don't accidentally kick off a job that won't be visible. */} + +
+ {/* Info Box */}
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 378a3005..9300607f 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1242,6 +1242,13 @@ "regenerateButton": "Alle Vorschaubilder neu generieren", "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.", + "regeneratePreviewsButton": "Alle Vorschauen neu generieren", + "previewsRegenerateStarted": "Neugenerierung der Lightbox-Vorschauen gestartet", + "previewsRegenerateError": "Neugenerierung der Vorschauen konnte nicht gestartet werden", "saveSuccess": "Vorschaubild-Einstellungen gespeichert", "saveError": "Vorschaubild-Einstellungen konnten nicht gespeichert werden", "loadError": "Vorschaubild-Einstellungen konnten nicht geladen werden", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 2f6e41aa..16ee3bf0 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -948,6 +948,13 @@ "regenerateButton": "Regenerate All Thumbnails", "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.", + "regeneratePreviewsButton": "Regenerate All Previews", + "previewsRegenerateStarted": "Lightbox preview regeneration started", + "previewsRegenerateError": "Failed to start preview regeneration", "saveSuccess": "Thumbnail settings saved", "saveError": "Failed to save thumbnail settings", "loadError": "Failed to load thumbnail settings", diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8d8eeef0..216fafcd 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -91,6 +91,11 @@ export interface Photo { url: string; thumbnail_url?: string; 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). + preview_url?: string | null; secure_url_template?: string; download_url_template?: string; requires_token?: boolean;