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'); const { execFile } = require('child_process'); const { promisify } = require('util'); const execFileAsync = promisify(execFile); // Configure sharp for better memory management with large batches sharp.cache(false); // Disable cache to prevent memory buildup sharp.concurrency(2); // Limit concurrent operations // Camera RAW / DNG formats. Sharp's bundled libvips has no raw loader, so these // can't be fed to sharp() directly — instead we extract the full-resolution JPEG // preview that every RAW file embeds (via exiftool) and process THAT. Gated // strictly by extension, so nothing here runs for ordinary jpg/png/webp photos. const RAW_EXTENSIONS = new Set([ 'dng', 'cr2', 'cr3', 'nef', 'nrw', 'arw', 'sr2', 'srf', 'raf', 'rw2', 'orf', 'pef', 'srw', 'raw', '3fr', 'dcr', 'kdc' ]); function isRawFilename(name) { if (!name || typeof name !== 'string') return false; const ext = path.extname(name).toLowerCase().replace(/^\./, ''); return RAW_EXTENSIONS.has(ext); } /** * Extract the embedded full-resolution JPEG preview from a RAW/DNG file to a * temp .jpg and return its path. Tries the largest previews first * (JpgFromRaw → PreviewImage → ThumbnailImage). Throws if none can be extracted * or the result isn't a valid image — the caller treats that as a processing * failure (photo → 'failed'), same as any unreadable upload. */ async function extractRawPreview(rawPath) { const outDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-raw-')); const outPath = path.join(outDir, `${crypto.randomBytes(4).toString('hex')}.jpg`); const tags = ['-JpgFromRaw', '-PreviewImage', '-ThumbnailImage']; let lastErr; for (const tag of tags) { try { // `-b` writes the raw tag bytes to stdout; -w isn't reliable across tags, // so capture stdout as a buffer and write it ourselves. const { stdout } = await execFileAsync('exiftool', ['-b', tag, rawPath], { encoding: 'buffer', maxBuffer: 256 * 1024 * 1024, }); if (stdout && stdout.length > 0) { await fsp.writeFile(outPath, stdout); // Validate it's a real, decodable image before handing it to the pipeline. const meta = await sharp(outPath).metadata(); if (meta.width && meta.height) { return { path: outPath, cleanup: () => fsp.rm(outDir, { recursive: true, force: true }).catch(() => {}) }; } } } catch (err) { lastErr = err; } } await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {}); throw new Error(`No usable embedded preview in RAW file ${path.basename(rawPath)}: ${lastErr ? lastErr.message : 'no preview tag returned data'}`); } /** * Give a Sharp-processable local image path for `localPath`. For ordinary * images it's a pass-through (no cost). For RAW/DNG (by `sourceName` extension) * it extracts the embedded JPEG preview and returns that, plus the basename to * use for generated outputs so thumbnails/previews stay named after the source * rather than the random temp file. Always call `cleanup()` when done. */ async function withProcessableImage(localPath, sourceName) { if (!isRawFilename(sourceName)) { return { path: localPath, outputBasename: undefined, cleanup: () => {} }; } const { path: previewPath, cleanup } = await extractRawPreview(localPath); return { path: previewPath, outputBasename: path.basename(sourceName), cleanup }; } // 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. */ 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(); // Force regeneration: drop the existing object before writing the new one if (options.regenerate) { await storage.delete(thumbnailRelKey).catch(() => {}); } 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, failOnError: false }); // 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}`); // Clean up any partially uploaded object await storage.delete(thumbnailRelKey).catch(() => {}); 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, async (localPath) => { const proc = await withProcessableImage(localPath, sourceKey); try { return await generateThumbnail(proc.path, { regenerate: true, outputBasename: proc.outputBasename }); } finally { await proc.cleanup(); } }); } 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 = {}) { 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, failOnError: false }); // 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 } = require('./photoResolver'); let sourceKey; try { const event = await db('events').where('id', photo.event_id).first(); 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 (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...`); } const newHeroPath = await withLocalCopy(sourceKey, async (localPath) => { const proc = await withProcessableImage(localPath, sourceKey); try { return await generateHeroImage(proc.path, { regenerate: true, outputBasename: proc.outputBasename }); } finally { await proc.cleanup(); } }); 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. */ async function generatePreviewImage(imagePath, options = {}) { const filename = options.outputBasename || 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, async (localPath) => { const proc = await withProcessableImage(localPath, sourceKey); try { return await generatePreviewImage(proc.path, { regenerate: true, outputBasename: proc.outputBasename }); } finally { await proc.cleanup(); } }); 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 = { generateThumbnail, isThumbnailValid, ensureThumbnail, generateVideoPlaceholder, generateHeroImage, isHeroValid, ensureHeroImage, generatePreviewImage, isPreviewValid, ensurePreviewImage, extractCaptureDate, withLocalCopy, isRawFilename, extractRawPreview, withProcessableImage, RAW_EXTENSIONS, };