feat(lightbox): medium-resolution preview tier (#492)
Adds an opt-in lightbox preview tier so guests open photos against an
aspect-preserved ~1920px JPEG (~200–500 KB) instead of the full original
(often 5–12 MB). Originals are still served on Download.
Backend:
- imageProcessor: generatePreviewImage / isPreviewValid / ensurePreviewImage
using fit:'inside' + withoutEnlargement (longEdge 1920, q85, mozjpeg)
- migration 104: photos.preview_path + lightbox_preview_enabled setting
(off by default, JSON-stringified for SQLite/Postgres parity)
- GET /api/gallery/:slug/preview/:photoId — gallery-auth, lazy generation,
ETag based on mtime+photoId+watermarkHash
- preview_url surfaced in the photo response only when the toggle is on
- admin /thumbnails/regenerate-previews mirrors regenerate-thumbnails,
skipping videos
- backup walk + archive cleanup + photo-delete now include previews/
Frontend:
- PhotoLightbox uses photo.preview_url ?? photo.url (null-safe fallback)
- ThumbnailsTab gets a Lightbox Preview Tier card: opt-in toggle +
Regenerate All Previews button (gated until the toggle is on)
- en/de locale strings; nl/pt/ru/fr fall back to en
Tested end-to-end: 11 MB / 4000×3000 source → 985 KB / 1920×1440 preview,
381 ms first call, 7 ms cached, ~91% byte reduction.
This commit is contained in:
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user