Merge pull request #182 from the-luap/feat/new-features
fix: restore aspect-ratio layouts and improve hero image quality (#180)
This commit is contained in:
@@ -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.
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -137,8 +137,8 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
|
||||
{/* Hero Section */}
|
||||
<div className="relative h-[60vh] sm:h-[70vh] lg:h-[80vh] max-h-[700px] -mx-4 sm:-mx-6 lg:-mx-8 mb-8">
|
||||
<AuthenticatedImage
|
||||
src={heroPhoto.url}
|
||||
fallbackSrc={heroPhoto.thumbnail_url || undefined}
|
||||
src={heroPhoto.hero_url || heroPhoto.url}
|
||||
fallbackSrc={heroPhoto.url}
|
||||
alt={heroPhoto.filename}
|
||||
className="w-full h-full object-cover"
|
||||
style={{ objectPosition: heroImageAnchor }}
|
||||
|
||||
@@ -62,10 +62,11 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
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<BaseGalleryLayoutProps> = ({
|
||||
// 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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user