Merge pull request #496 from the-luap/feat/lightbox-preview-tier-492
feat(lightbox): medium-resolution preview tier (#492)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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_<filename>` 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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user