diff --git a/backend/migrations/core/069_add_hero_path.js b/backend/migrations/core/069_add_hero_path.js new file mode 100644 index 00000000..1c3732e4 --- /dev/null +++ b/backend/migrations/core/069_add_hero_path.js @@ -0,0 +1,22 @@ +/** + * Migration 069: Add hero image path to photos table + * - photos.hero_path: path to hero-optimized image (1920x1080) for gallery headers + */ + +const { addColumnIfNotExists } = require('../helpers'); + +exports.up = async function(knex) { + console.log('Running migration: 069_add_hero_path'); + + // photos.hero_path (nullable - path to hero-optimized image) + await addColumnIfNotExists(knex, 'photos', 'hero_path', (table) => { + table.string('hero_path', 512); + }); + + console.log('Migration 069_add_hero_path completed'); +}; + +exports.down = async function(knex) { + console.log('Rollback: 069_add_hero_path'); + // Keep columns (safe rollback not removing data). Intentionally no-op. +}; diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 7c028cf2..5898ff5f 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 } = require('../services/imageProcessor'); +const { ensureThumbnail, ensureHeroImage } = require('../services/imageProcessor'); // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); @@ -385,6 +385,8 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { filename: photo.filename, url: photoUrl, 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}`, 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, @@ -982,6 +984,92 @@ router.get('/:slug/thumbnail/:photoId', } ); +// Serve hero-optimized image (1920x1080 for full-width hero sections) +router.get('/:slug/hero/: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' }); + } + + // Check if this is a video - videos don't get hero images + const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/')); + if (isVideo) { + // For videos, redirect to the regular photo endpoint + return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`); + } + + // Ensure hero image exists and is valid, regenerate if needed + const heroPath = await ensureHeroImage(photo); + + if (!heroPath) { + // If hero generation fails, fall back to original photo + logger.warn(`Failed to generate hero image for photo ${photoId}, falling back to original`); + return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`); + } + + const heroFullPath = path.join(getStoragePath(), heroPath); + const fs = require('fs'); + + // Verify file exists before attempting to serve + if (!fs.existsSync(heroFullPath)) { + logger.error('Hero image file does not exist at resolved path', { + slug: req.params.slug, + photoId, + eventId: req.event.id, + resolvedPath: heroFullPath + }); + return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`); + } + + // Get file stats for ETag + const stat = fs.statSync(heroFullPath); + const etag = `"hero-${photoId}-${stat.mtime.getTime()}"`; + + // Check if client has valid cached version + if (req.headers['if-none-match'] === etag) { + return res.status(304).end(); + } + + // Check if watermarks should be applied + const watermarkSettings = await watermarkService.getWatermarkSettings(); + + res.set({ + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'private, max-age=3600', // Cache for 1 hour + 'Cross-Origin-Resource-Policy': 'cross-origin', + 'X-Content-Type-Options': 'nosniff', + 'X-Hero-Image': 'true', + 'ETag': etag + }); + + if (watermarkSettings && watermarkSettings.enabled) { + // Apply watermark to hero image + const watermarkedBuffer = await watermarkService.applyWatermark(heroFullPath, watermarkSettings); + res.send(watermarkedBuffer); + } else { + // Send hero image without watermark + res.sendFile(path.resolve(heroFullPath)); + } + } catch (error) { + logger.error('Error serving hero image:', { + error: error.message, + photoId: req.params.photoId, + eventId: req.event?.id + }); + // Fall back to original photo on any error + 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/imageProcessor.js b/backend/src/services/imageProcessor.js index e22e490d..322f5ccf 100644 --- a/backend/src/services/imageProcessor.js +++ b/backend/src/services/imageProcessor.js @@ -287,4 +287,167 @@ async function generateVideoPlaceholder(originalFilename, options = {}) { } } -module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail, generateVideoPlaceholder }; +// Hero image settings - optimized for large displays +const DEFAULT_HERO_WIDTH = 1920; +const DEFAULT_HERO_HEIGHT = 1080; +const DEFAULT_HERO_QUALITY = 85; +const DEFAULT_HERO_FORMAT = 'jpeg'; + +const getHeroPath = () => path.join(getStoragePath(), 'heroes'); + +/** + * 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 = path.basename(imagePath); + const heroFilename = `hero_${filename}`; + const heroDir = getHeroPath(); + const heroPath = path.join(heroDir, heroFilename); + + // Ensure hero directory exists + await fs.mkdir(heroDir, { recursive: true }); + + // Check if we need to regenerate + if (options.regenerate) { + try { + await fs.unlink(heroPath); + logger.info(`Deleted existing hero image: ${heroPath}`); + } catch (err) { + // File might not exist, that's okay + } + } + + 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'); + } + + // Calculate dimensions to maintain aspect ratio while fitting within hero bounds + const heroWidth = options.width || DEFAULT_HERO_WIDTH; + const heroHeight = options.height || DEFAULT_HERO_HEIGHT; + const quality = options.quality || DEFAULT_HERO_QUALITY; + + // Create sharp instance with memory-efficient settings + let sharpInstance = sharp(imagePath, { + limitInputPixels: 268402689, + sequentialRead: true, + failOnError: false + }); + + // Resize to fit hero dimensions while maintaining aspect ratio + // Use 'cover' to fill the hero area (crops if needed) + sharpInstance = sharpInstance.resize(heroWidth, heroHeight, { + withoutEnlargement: false, // Allow upscaling for small images + fit: 'cover', + position: 'center' + }); + + // Apply JPEG format with high quality + sharpInstance = sharpInstance.jpeg({ + quality: quality, + progressive: true, + mozjpeg: true + }); + + // Save the hero image + await sharpInstance.toFile(heroPath); + + // Verify the hero image was created successfully + const stats = await fs.stat(heroPath); + if (stats.size === 0) { + throw new Error('Generated hero image is empty'); + } + + logger.info(`Generated hero image for ${filename}: ${heroPath}`); + return path.relative(getStoragePath(), heroPath); + } catch (error) { + const msg = (error && error.message) ? error.message : String(error); + logger.error(`Failed to generate hero image for ${filename}: ${msg}`); + + // Clean up any partially created file + try { + await fs.unlink(heroPath); + } catch (unlinkErr) { + // Ignore unlink errors + } + + return null; + } +} + +/** + * Check if a hero image exists and is valid + */ +async function isHeroValid(heroPath) { + try { + const fullPath = path.join(getStoragePath(), heroPath); + const stats = await fs.stat(fullPath); + + if (stats.size === 0) { + return false; + } + + // Try to read metadata to ensure it's a valid image + await sharp(fullPath).metadata(); + return true; + } catch (error) { + return false; + } +} + +/** + * Ensure a hero image exists for a photo, regenerate if needed + */ +async function ensureHeroImage(photo) { + const { db } = require('../database/db'); + const { resolvePhotoFilePath } = require('./photoResolver'); + + let originalPath; + try { + const event = await db('events').where('id', photo.event_id).first(); + originalPath = resolvePhotoFilePath(event, photo); + logger.info(`Ensuring hero image for photo ${photo.id} from source: ${originalPath}`); + } catch (e) { + const msg = (e && e.message) ? e.message : String(e); + logger.error(`Failed to resolve original path for hero image (photo ${photo.id}): ${msg}`); + return null; + } + + // Check if hero image exists and is valid + 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...`); + } + + // Generate new hero image + const newHeroPath = await generateHeroImage(originalPath, { regenerate: true }); + + if (newHeroPath) { + // Update database with new hero path + await db('photos') + .where({ id: photo.id }) + .update({ hero_path: newHeroPath }); + + logger.info(`Regenerated hero image for photo ${photo.id}`); + return newHeroPath; + } + + return null; +} + +module.exports = { + generateThumbnail, + isThumbnailValid, + ensureThumbnail, + generateVideoPlaceholder, + generateHeroImage, + isHeroValid, + ensureHeroImage +}; diff --git a/frontend/src/components/gallery/HeroHeader.tsx b/frontend/src/components/gallery/HeroHeader.tsx index dd81a314..891ff638 100644 --- a/frontend/src/components/gallery/HeroHeader.tsx +++ b/frontend/src/components/gallery/HeroHeader.tsx @@ -137,8 +137,8 @@ export const HeroHeader: React.FC = ({ {/* Hero Section */}
= ({ const aspectRatio = photoWidth / photoHeight; // Calculate height based on column width and aspect ratio - // Clamp to reasonable min/max heights for visual consistency + // Use dynamic constraints based on column width to preserve aspect ratio variation + // This allows panoramic images to be short and tall portraits to be tall const calculatedHeight = columnWidth / aspectRatio; - const minHeight = 150; - const maxHeight = 500; + const minHeight = Math.max(80, columnWidth * 0.25); // Allow wide panoramics (4:1) + const maxHeight = columnWidth * 2.5; // Allow tall portraits (1:2.5) return Math.max(minHeight, Math.min(maxHeight, calculatedHeight)); }, [photo.width, photo.height, columnWidth]); @@ -361,11 +362,13 @@ export const MasonryGalleryLayout: React.FC = ({ // Add photo to shortest column cols[shortestCol].push(photo); - // Estimate height based on aspect ratio + // Estimate height based on aspect ratio (using same constraints as MasonryPhoto) const photoWidth = photo.width || 800; const photoHeight = photo.height || 600; const aspectRatio = photoWidth / photoHeight; - const estimatedHeight = Math.max(150, Math.min(500, approxColWidth / aspectRatio)); + const minHeightConstraint = Math.max(80, approxColWidth * 0.25); + const maxHeightConstraint = approxColWidth * 2.5; + const estimatedHeight = Math.max(minHeightConstraint, Math.min(maxHeightConstraint, approxColWidth / aspectRatio)); colHeights[shortestCol] += estimatedHeight + gutter; }); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index eeaacd29..d3affcc4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -71,6 +71,7 @@ export interface Photo { filename: string; url: string; thumbnail_url?: string; + hero_url?: string; // Hero-optimized image URL (1920x1080) for full-width hero sections secure_url_template?: string; download_url_template?: string; requires_token?: boolean;