fix(import): capture photo dimensions in fileWatcher + s3AutoImporter (#447)
The aspect-aware gallery layouts (masonry / mosaic / justified) read photo.width and photo.height to size each card to the source's real proportions. Two import paths were inserting rows without those fields, which forced MasonryGalleryLayout to fall back to a hard-coded 800×600 default — every card came out the same shape, so users reported masonry as "always cropped to 1:1ish" no matter which thumbnail fit mode they chose. - fileWatcher.js: extract dims with sharp.metadata() before insert. - s3AutoImporter.js: same, materialising a tmp local copy via withLocalCopy so it works in S3 mode. - migration 090: backfill any pre-existing rows with NULL dims (skips videos, skips S3 deployments — those need the writer fix alone since migrations cannot reach the storage backend). - imageProcessor.js: change DEFAULT_THUMBNAIL_FIT from 'cover' to 'inside' (only kicks in when the seed setting is missing — existing installs keep their saved value). Add UI tooltip recommending 'inside' for masonry/mosaic/justified, 'cover' for uniform grids. i18n covers all six locales.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
const chokidar = require('chokidar');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const sharp = require('sharp');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
|
||||
@@ -91,7 +92,23 @@ async function processNewPhoto(filePath) {
|
||||
// Calculate relative thumbnail path
|
||||
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
|
||||
const mimeType = detectedMime || (isVideo ? 'video/mp4' : 'image/jpeg');
|
||||
|
||||
|
||||
// Capture image dimensions so aspect-aware layouts (masonry / mosaic /
|
||||
// justified) can size each card to the photo's real proportions
|
||||
// instead of the 800×600 fallback in MasonryGalleryLayout (#447).
|
||||
// Skip videos — those would need ffprobe.
|
||||
let dimensions = null;
|
||||
if (!isVideo) {
|
||||
try {
|
||||
const metadata = await sharp(filePath).metadata();
|
||||
if (metadata.width && metadata.height) {
|
||||
dimensions = { width: metadata.width, height: metadata.height };
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(`Could not read image dimensions for ${filename}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if photo already exists (by filename or path, to handle replacements)
|
||||
const existingPhoto = await db('photos')
|
||||
.where({ event_id: event.id })
|
||||
@@ -110,7 +127,8 @@ async function processNewPhoto(filePath) {
|
||||
thumbnail_path: relativeThumbPath,
|
||||
type: isVideo ? 'video' : photoType,
|
||||
size_bytes: stats.size,
|
||||
mime_type: mimeType
|
||||
mime_type: mimeType,
|
||||
...(dimensions && { width: dimensions.width, height: dimensions.height })
|
||||
}).returning('id');
|
||||
const photoId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
|
||||
@@ -15,7 +15,13 @@ sharp.concurrency(2); // Limit concurrent operations
|
||||
// Default thumbnail settings
|
||||
const DEFAULT_THUMBNAIL_WIDTH = 300;
|
||||
const DEFAULT_THUMBNAIL_HEIGHT = 300;
|
||||
const DEFAULT_THUMBNAIL_FIT = 'cover'; // 'cover' for square crops
|
||||
// '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';
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
|
||||
const path = require('path');
|
||||
const mime = require('mime-types');
|
||||
const sharp = require('sharp');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getStorage } = require('./storage');
|
||||
const { withLocalCopy } = require('./imageProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const POLL_INTERVAL_MS = parseInt(process.env.STORAGE_AUTO_IMPORT_INTERVAL_MS || `${5 * 60 * 1000}`, 10);
|
||||
@@ -98,6 +100,26 @@ async function processEvent(event, storage) {
|
||||
const isVideo = mimeType.startsWith('video/');
|
||||
if (!isImage && !isVideo) continue;
|
||||
|
||||
// Capture image dimensions so aspect-aware layouts (masonry /
|
||||
// mosaic / justified) can size each card to the photo's real
|
||||
// proportions instead of the 800×600 fallback (#447). Materialize
|
||||
// a tmp local copy via withLocalCopy — withLocalCopy handles the
|
||||
// S3 download + cleanup. Skip videos (would need ffprobe).
|
||||
let dimensions = null;
|
||||
if (isImage) {
|
||||
try {
|
||||
dimensions = await withLocalCopy(entry.key, async (localPath) => {
|
||||
const metadata = await sharp(localPath).metadata();
|
||||
if (metadata.width && metadata.height) {
|
||||
return { width: metadata.width, height: metadata.height };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
} catch (err) {
|
||||
logger.debug(`[s3AutoImporter] could not read dimensions for ${entry.key}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const insertResult = await db('photos').insert({
|
||||
event_id: event.id,
|
||||
@@ -110,6 +132,7 @@ async function processEvent(event, storage) {
|
||||
mime_type: mimeType,
|
||||
source_origin: 'managed',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
...(dimensions && { width: dimensions.width, height: dimensions.height }),
|
||||
}).returning('id');
|
||||
const photoId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user