const sharp = require('sharp'); const exifr = require('exifr'); const path = require('path'); const fsp = require('fs').promises; const os = require('os'); const crypto = require('crypto'); const logger = require('../utils/logger'); const { db } = require('../database/db'); const { getStorage } = require('./storage'); // Configure sharp for better memory management with large batches sharp.cache(false); // Disable cache to prevent memory buildup sharp.concurrency(2); // Limit concurrent operations // Default thumbnail settings const DEFAULT_THUMBNAIL_WIDTH = 300; const DEFAULT_THUMBNAIL_HEIGHT = 300; // 'inside' preserves the source aspect ratio (output ≤ width × height). // This is the right default for masonry / mosaic / justified layouts — // the gallery sizes each card from photo.width/height and renders the // thumbnail with object-cover, so a thumb that already matches the // source aspect doesn't get re-cropped (#447). Admins who want // uniform 1:1 grid tiles can switch to 'cover' in the thumbnail settings. const DEFAULT_THUMBNAIL_FIT = 'inside'; const DEFAULT_THUMBNAIL_QUALITY = 85; const DEFAULT_THUMBNAIL_FORMAT = 'jpeg'; // Hero image settings - optimized for large displays 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) { return null; } // Try to parse as JSON first (in case it's a JSON-encoded string like '"cover"') try { return JSON.parse(value); } catch (e) { // If it's not valid JSON, return the raw value return value; } } // Validate that fit value is valid for Sharp function validateFitValue(fit) { const validFitValues = ['cover', 'contain', 'fill', 'inside', 'outside']; if (fit && validFitValues.includes(fit)) { return fit; } return DEFAULT_THUMBNAIL_FIT; } // Get thumbnail settings from database async function getThumbnailSettings() { try { const settings = await db('app_settings') .whereIn('setting_key', [ 'thumbnail_width', 'thumbnail_height', 'thumbnail_fit', 'thumbnail_quality', 'thumbnail_format' ]) .select('setting_key', 'setting_value'); const settingsMap = {}; settings.forEach(s => { settingsMap[s.setting_key] = parseSettingValue(s.setting_value); }); // Parse and validate fit value const fitValue = validateFitValue(settingsMap.thumbnail_fit); return { width: parseInt(settingsMap.thumbnail_width) || DEFAULT_THUMBNAIL_WIDTH, height: parseInt(settingsMap.thumbnail_height) || DEFAULT_THUMBNAIL_HEIGHT, fit: fitValue, quality: parseInt(settingsMap.thumbnail_quality) || DEFAULT_THUMBNAIL_QUALITY, format: settingsMap.thumbnail_format || DEFAULT_THUMBNAIL_FORMAT }; } catch (error) { // If database is not ready or settings don't exist, use defaults logger.warn('Could not fetch thumbnail settings, using defaults:', error.message); return { width: DEFAULT_THUMBNAIL_WIDTH, height: DEFAULT_THUMBNAIL_HEIGHT, fit: DEFAULT_THUMBNAIL_FIT, quality: DEFAULT_THUMBNAIL_QUALITY, format: DEFAULT_THUMBNAIL_FORMAT }; } } const contentTypeFor = (format) => { if (format === 'png') return 'image/png'; if (format === 'webp') return 'image/webp'; return 'image/jpeg'; }; /** * Generate a thumbnail from a local source image path. The output is written * to the storage backend (local fs or S3) under `thumbnails/thumb_` * and the relative storage key is returned for DB persistence. * * Callers must ensure the source is on the local filesystem. For S3 mode * regeneration flows, fetch via `withLocalCopy(storage, sourceKey, fn)` first. * * options.outputBasename — override the basename portion of the thumbnail * filename (default: basename of imagePath). Used for external/reference * photos where the source basename can collide across events (#423) — the * import path passes a per-photo unique basename so two events both * referencing `IMG_0001.jpg` don't clobber each other's thumbnail. */ /** * The dimensions a viewer actually sees, given EXIF orientation (#1185). * * sharp reports `metadata.width`/`height` as the pixels are stored, not as * they are displayed. Orientation values 5-8 carry a 90° rotation, so for * those the two are swapped — which is why a portrait photo from a body that * tags rather than rotates was landing in the database as landscape, and why * masonry and justified layouts sized its tile with the wrong aspect ratio on * top of the image itself being unrotated. * * Everything that renders these photos now applies `.rotate()`, so the stored * numbers have to describe the rotated result to match. * * @param {Object} metadata - a sharp metadata object * @returns {{ width: number|null, height: number|null }} */ function orientedDimensions(metadata) { if (!metadata || !metadata.width || !metadata.height) return { width: null, height: null }; const swap = metadata.orientation >= 5 && metadata.orientation <= 8; return { width: swap ? metadata.height : metadata.width, height: swap ? metadata.width : metadata.height, }; } async function generateThumbnail(imagePath, options = {}) { const sourceBasename = path.basename(imagePath); const outputBasename = options.outputBasename || sourceBasename; const thumbnailFilename = `thumb_${outputBasename}`; const thumbnailRelKey = path.posix.join('thumbnails', thumbnailFilename); const storage = getStorage(); // Get thumbnail settings const settings = await getThumbnailSettings(); // `options.regenerate` deliberately does NOT delete the existing object first // (#1129). // // It used to, and the delete ran BEFORE sharp had even opened the source — so // a source that could not be read (a NAS mount that blipped, a corrupt file) // left the old thumbnail already gone and returned null, with the database // still pointing at it. One bulk regeneration during a mount outage could // therefore strip every canonical thumbnail in a reference gallery. // // Nothing is lost by dropping it: LocalFsStorage.put stages to a temp file and // renames over the target, which replaces atomically, and an S3 put overwrites // by key. The delete only added a window with no thumbnail at all. try { // First, verify the source image is complete and valid const metadata = await sharp(imagePath).metadata(); if (!metadata.width || !metadata.height) { throw new Error('Invalid image metadata - file may be incomplete'); } let sharpInstance = sharp(imagePath, { limitInputPixels: 268402689, // ~16k x 16k max sequentialRead: true, failOn: 'none' }); // Apply EXIF orientation before resizing (#1185). Without this a photo // whose Orientation tag is not 1 — routine for portrait shots on bodies // that tag rather than rotate the sensor data — is resized from the raw // pixels and comes out sideways. `.withMetadata(false)` below then strips // the tag, so the browser has no hint left to correct it either. // // Unconditional: this pipeline never passes `animated: true`, so it // already flattens a multi-frame source. Guarding on `pages` would protect // an animation that was being discarded anyway while leaving the output in // raw orientation against corrected dimensions. sharpInstance = sharpInstance.rotate(); // Strip EXIF/metadata from thumbnails (privacy: prevent GPS leak etc.) sharpInstance = sharpInstance.withMetadata(false); sharpInstance = sharpInstance.resize(settings.width, settings.height, { withoutEnlargement: true, fit: settings.fit, position: 'center' }); if (settings.format === 'jpeg') { sharpInstance = sharpInstance.jpeg({ quality: settings.quality, progressive: true, mozjpeg: true }); } else if (settings.format === 'png') { sharpInstance = sharpInstance.png({ quality: settings.quality, compressionLevel: 9, progressive: true }); } else if (settings.format === 'webp') { sharpInstance = sharpInstance.webp({ quality: settings.quality, effort: 4 }); } const buffer = await sharpInstance.toBuffer(); if (!buffer || buffer.length === 0) { throw new Error('Generated thumbnail is empty'); } await storage.put(thumbnailRelKey, buffer, { contentType: contentTypeFor(settings.format) }); return thumbnailRelKey; } catch (error) { const msg = (error && error.message) ? error.message : String(error); logger.error(`Failed to generate thumbnail for ${sourceBasename}: ${msg}`); // No cleanup delete here either, for the same reason (#1129). This was // "clean up any partially uploaded object", but there cannot be one: // storage.put is the LAST statement in the try, every throw above it // happens before anything is written, and put unlinks its own temp file on // failure. The only object this could remove is the PREVIOUS, valid // rendition — exactly the thumbnail a failed regeneration must leave alone. return null; } } /** * Check if a thumbnail exists and is valid. For local-fs storage we open the * file with sharp to confirm it parses; for S3 we trust the byte-integrity * checks built into the protocol and only verify size > 0. */ async function isThumbnailValid(thumbnailPath) { const storage = getStorage(); try { const stat = await storage.stat(thumbnailPath); if (!stat || stat.size === 0) { return false; } if (storage.kind() === 'local') { const localPath = storage.resolveLocalPath(thumbnailPath); await sharp(localPath).metadata(); } return true; } catch (error) { return false; } } /** * Wraps a callback that needs the source image as a local file. In local-fs * mode the storage path is used directly (no copy); in S3 mode the object is * streamed to a tmp file which is removed afterwards. */ async function withLocalCopy(sourceKey, fn) { const storage = getStorage(); if (storage.kind() === 'local') { return fn(storage.resolveLocalPath(sourceKey)); } const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-src-')); const tmpPath = path.join(tmpDir, `${crypto.randomBytes(4).toString('hex')}_${path.basename(sourceKey)}`); try { await storage.getToFile(sourceKey, tmpPath); return await fn(tmpPath); } finally { await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } } /** * Regenerate thumbnail if it's broken or missing. * * Works for both managed photos (stored via the storage backend, possibly * S3) and external/reference photos (#423 — sourced from a local mount * outside the managed storage tree, e.g. NAS over SMB/NFS). External * photos historically had thumbnail_path=null, which forced the gallery * to fall back to streaming the full original on every tile — minutes of * load time for a 100-photo NAS-mounted gallery. */ async function ensureThumbnail(photo) { const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver'); const event = await db('events').where('id', photo.event_id).first(); if (!event) { logger.error(`ensureThumbnail: event ${photo.event_id} not found for photo ${photo.id}`); return null; } // Check if thumbnail exists and is valid (works for any source). if (photo.thumbnail_path) { const isValid = await isThumbnailValid(photo.thumbnail_path); if (isValid) { return photo.thumbnail_path; } logger.warn(`Invalid thumbnail detected for photo ${photo.id}, regenerating...`); } const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference'; let newThumbnailPath; if (isExternal) { // External: source is on a local mount path. No withLocalCopy needed // (storage-backend abstraction doesn't apply — this is a direct fs // read). Use a per-photo unique outputBasename so two events both // referencing the same NAS basename can't clobber each other's thumb. let localPath; try { localPath = resolvePhotoFilePath(event, photo); } catch (e) { logger.error(`Failed to resolve external file for thumbnail (photo ${photo.id}): ${e.message}`); return null; } const sourceBasename = path.basename(photo.external_relpath || photo.filename || `photo-${photo.id}`); const outputBasename = `ext${photo.id}_${sourceBasename}`; logger.info(`Ensuring thumbnail for external photo ${photo.id} from ${localPath}`); newThumbnailPath = await generateThumbnail(localPath, { regenerate: true, outputBasename }); } else { let sourceKey; try { sourceKey = resolvePhotoStorageKey(event, photo); } catch (e) { logger.error(`Failed to resolve original key for thumbnail (photo ${photo.id}): ${e.message}`); return null; } logger.info(`Ensuring thumbnail for photo ${photo.id} from key: ${sourceKey}`); newThumbnailPath = await withLocalCopy(sourceKey, (localPath) => generateThumbnail(localPath, { regenerate: true }) ); } if (newThumbnailPath) { await db('photos') .where({ id: photo.id }) .update({ thumbnail_path: newThumbnailPath }); logger.info(`Regenerated thumbnail for photo ${photo.id}`); return newThumbnailPath; } return null; } async function generateVideoPlaceholder(originalFilename, options = {}) { const parsed = path.parse(originalFilename || ''); const baseName = parsed.name || 'video'; const thumbnailFilename = `thumb_${baseName}.jpg`; const thumbnailRelKey = path.posix.join('thumbnails', thumbnailFilename); const storage = getStorage(); const settings = await getThumbnailSettings(); const width = settings.width || DEFAULT_THUMBNAIL_WIDTH; const height = settings.height || DEFAULT_THUMBNAIL_HEIGHT; if (options.regenerate) { await storage.delete(thumbnailRelKey).catch(() => {}); } try { const svg = ` VIDEO `; const buffer = await sharp(Buffer.from(svg)) .resize(width, height, { fit: 'cover' }) .jpeg({ quality: settings.quality || DEFAULT_THUMBNAIL_QUALITY }) .toBuffer(); await storage.put(thumbnailRelKey, buffer, { contentType: 'image/jpeg' }); return thumbnailRelKey; } catch (error) { logger.error('Failed to generate video placeholder thumbnail:', error.message); return null; } } /** * Generate a hero-optimized image for gallery headers * Outputs a 1920x1080 image suitable for full-width hero sections */ async function generateHeroImage(imagePath, options = {}) { // outputBasename lets callers disambiguate sources that share a basename — // two events referencing the same NAS filename would otherwise clobber each // other's hero. Same contract as generateThumbnail and generatePreviewImage. const filename = options.outputBasename || path.basename(imagePath); const heroFilename = `hero_${filename}`; const heroRelKey = path.posix.join('heroes', heroFilename); const storage = getStorage(); if (options.regenerate) { await storage.delete(heroRelKey).catch(() => {}); } try { const metadata = await sharp(imagePath).metadata(); if (!metadata.width || !metadata.height) { throw new Error('Invalid image metadata - file may be incomplete'); } const heroWidth = options.width || DEFAULT_HERO_WIDTH; const heroHeight = options.height || DEFAULT_HERO_HEIGHT; const quality = options.quality || DEFAULT_HERO_QUALITY; let sharpInstance = sharp(imagePath, { limitInputPixels: 268402689, sequentialRead: true, failOn: 'none' }); // EXIF orientation, same reasoning as generateThumbnail (#1185) — and // unconditional for the same reason: no `animated: true` on the input. sharpInstance = sharpInstance.rotate(); // Strip EXIF/metadata from hero images (privacy: prevent GPS leak etc.) sharpInstance = sharpInstance.withMetadata(false); sharpInstance = sharpInstance.resize(heroWidth, heroHeight, { withoutEnlargement: false, fit: 'cover', position: 'center' }); sharpInstance = sharpInstance.jpeg({ quality: quality, progressive: true, mozjpeg: true }); const buffer = await sharpInstance.toBuffer(); if (!buffer || buffer.length === 0) { throw new Error('Generated hero image is empty'); } await storage.put(heroRelKey, buffer, { contentType: 'image/jpeg' }); logger.info(`Generated hero image for ${filename} → ${heroRelKey}`); return heroRelKey; } catch (error) { const msg = (error && error.message) ? error.message : String(error); logger.error(`Failed to generate hero image for ${filename}: ${msg}`); await storage.delete(heroRelKey).catch(() => {}); return null; } } /** * Check if a hero image exists and is valid */ async function isHeroValid(heroPath) { const storage = getStorage(); try { const stat = await storage.stat(heroPath); if (!stat || stat.size === 0) { return false; } if (storage.kind() === 'local') { const localPath = storage.resolveLocalPath(heroPath); await sharp(localPath).metadata(); } return true; } catch (error) { return false; } } /** * Ensure a hero image exists for a photo, regenerate if needed */ async function ensureHeroImage(photo) { const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver'); let event; try { event = await db('events').where('id', photo.event_id).first(); } catch (e) { logger.error(`Failed to load event for hero image (photo ${photo.id}): ${e.message}`); return null; } if (photo.hero_path) { const isValid = await isHeroValid(photo.hero_path); if (isValid) { return photo.hero_path; } logger.warn(`Invalid hero image detected for photo ${photo.id}, regenerating...`); } // 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 heroIsExternal = photo.source_origin === 'external' || photo.source_origin === 'reference'; let newHeroPath; if (heroIsExternal) { // 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, (localPath) => generateHeroImage(localPath, { regenerate: true }) ); if (newHeroPath) { await db('photos') .where({ id: photo.id }) .update({ hero_path: newHeroPath }); logger.info(`Regenerated hero image for photo ${photo.id}`); return newHeroPath; } 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. * * ENCODING follows the source, it is not always JPEG. JPEG has no alpha * channel and no second frame, so encoding everything as JPEG flattened a * transparent PNG onto a solid background and reduced an animated GIF to its * first frame — for every consumer of this tier, not just the lightbox. * Sources with alpha or more than one page are encoded as WebP instead, which * carries both and is still far smaller than the original. * * The output extension is rewritten to match what was actually written. * Previously the source basename was kept verbatim, so a PNG source produced * `preview_foo.png` holding JPEG bytes — harmless while the route hard-coded * image/jpeg, and actively wrong now that the encoding varies. Old keys keep * working: they are still JPEG and still served as such. */ async function generatePreviewImage(imagePath, options = {}) { // outputBasename lets callers disambiguate sources that share a basename // (external mounts, see ensurePreviewImage) — same contract as // generateThumbnail. const filename = options.outputBasename || path.basename(imagePath); const storage = getStorage(); // Probed BEFORE the key is built: the extension has to match the encoding, // and the encoding depends on what the source turns out to be. let probe; try { probe = await sharp(imagePath).metadata(); } catch (error) { const msg = (error && error.message) ? error.message : String(error); logger.error(`Failed to read metadata for preview of ${filename}: ${msg}`); return null; } const isAnimated = (probe.pages || 1) > 1; const needsWebp = isAnimated || probe.hasAlpha === true; const base = filename.replace(/\.[^./\\]+$/, ''); const previewFilename = `preview_${base}.${needsWebp ? 'webp' : 'jpg'}`; const previewRelKey = path.posix.join('previews', previewFilename); if (options.regenerate) { await storage.delete(previewRelKey).catch(() => {}); } try { const metadata = probe; 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, failOn: 'none', // Without this an animated source is opened as its first frame only, and // every later frame is discarded before the resize ever sees it. // limitInputPixels still applies, and sharp counts an animated input as // width x (height x pages) — so a pathological GIF is rejected rather // than decoded, and the caller falls back to the original. animated: isAnimated, }); // EXIF orientation (#1185). Guarded here and not in the thumbnail/hero // generators because this one DOES open multi-frame sources with // `animated: true` above, and `.rotate()` would flatten them to a single // frame — trading an animation for an orientation. // // Which leaves one corner unsolved: a multi-frame source that also carries // an orientation tag keeps its raw orientation here while the thumbnail // and the stored dimensions describe the rotated one. GIF has no EXIF at // all and animated WebP effectively never sets it. if (!isAnimated) { sharpInstance = sharpInstance.rotate(); } // 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 = needsWebp ? sharpInstance.webp({ quality }) : 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: needsWebp ? 'image/webp' : '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). * * Handles both managed photos (via the storage backend, possibly S3) and * external/reference photos (#1078 — sourced from a local mount outside the * managed storage tree). Externals used to have no branch here at all: * resolvePhotoStorageKey returns null for them by design, that null reached * withLocalCopy, and the throw put every lightbox open back on the full-size * original — the exact cost the preview tier (#492) exists to avoid. */ async function ensurePreviewImage(photo) { const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver'); let event; try { 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 load event for preview (photo ${photo.id}): ${msg}`); return null; } if (!event) { logger.error(`ensurePreviewImage: event ${photo.event_id} not found for photo ${photo.id}`); 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 isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference'; let newPreviewPath; if (isExternal) { // Mirrors ensureThumbnail's external branch: the source is a direct fs // read off the mount, so no withLocalCopy. The per-photo outputBasename // keeps two events that reference the same NAS basename from clobbering // each other's preview. let localPath; try { localPath = resolvePhotoFilePath(event, photo); } catch (e) { logger.error(`Failed to resolve external file for preview (photo ${photo.id}): ${e.message}`); return null; } const sourceBasename = path.basename(photo.external_relpath || photo.filename || `photo-${photo.id}`); const outputBasename = `ext${photo.id}_${sourceBasename}`; logger.info(`Ensuring preview for external photo ${photo.id} from ${localPath}`); newPreviewPath = await generatePreviewImage(localPath, { regenerate: true, outputBasename }); } else { let sourceKey; try { 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 (!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 documented null-on-failure contract instead of feeding // null into withLocalCopy, which throws out of this function. logger.warn(`No managed storage key for preview (photo ${photo.id}); skipping preview generation`); return null; } 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 */ async function extractCaptureDate(imagePath) { try { const exif = await exifr.parse(imagePath, { pick: ['DateTimeOriginal', 'CreateDate', 'DateTimeDigitized', 'ModifyDate'] }); if (!exif) { return null; } const captureDate = exif.DateTimeOriginal || exif.CreateDate || exif.DateTimeDigitized || exif.ModifyDate; if (captureDate) { if (captureDate instanceof Date) { const now = new Date(); const minDate = new Date('1990-01-01'); if (captureDate > minDate && captureDate <= now) { return captureDate; } } if (typeof captureDate === 'string') { const parsed = new Date(captureDate); if (!isNaN(parsed.getTime())) { return parsed; } } } return null; } catch (error) { logger.debug(`Could not extract EXIF date from ${path.basename(imagePath)}:`, error.message); return null; } } module.exports = { orientedDimensions, generateThumbnail, isThumbnailValid, ensureThumbnail, generateVideoPlaceholder, generateHeroImage, isHeroValid, ensureHeroImage, generatePreviewImage, isPreviewValid, ensurePreviewImage, extractCaptureDate, withLocalCopy, };