fix(images): respect EXIF orientation in thumbnails, heroes and previews (#1194)
* fix(images): respect EXIF orientation in thumbnails, heroes and previews (#1185) generateThumbnail, generateHeroImage and generatePreviewImage went straight from sharp(imagePath) to .resize(), so a photo whose Orientation tag is not 1 — routine for portrait shots on bodies that tag rather than rotate the sensor data — was resized from the raw frame and came out sideways. The same pipelines then call .withMetadata(false), stripping the tag from the output, so nothing downstream could correct it either. The download path already had this right: resizeToBox calls probe.rotate() for stills, which is why the same photo looked correct on download and rotated in the gallery. All three generators now do the same, guarded to stills for the reason resizeToBox already documents — .rotate() flattens a multi-frame source. The reporter also spotted the half that compounds it: photos.width/height were stored from sharp's metadata, which reports pixels as STORED, not as displayed. For orientation 5-8 those are swapped, so a portrait photo landed in the database as landscape and masonry/justified sized its tile with the wrong aspect ratio on top of the image being unrotated. A shared orientedDimensions() helper now does that conversion at all four capture sites — managed upload, background processing, external import and the dimension repair — so the stored numbers describe the rotated result the generators now produce. Existing rows keep their pre-rotation dimensions until the photo is reprocessed; the images themselves correct on the next thumbnail/preview regeneration. Tests fail on the unfixed generators — verified by reverting the rotate calls and the swap, which fails 4 of the 7. * fix(images): orient dimensions on every ingest path, and stop guarding rotate where it protects nothing (#1185) Review found the first cut covered four of eight dimension-capture sites. The filesystem watcher, the S3 auto-importer, the v1 upload API and replace-by-name all still persisted raw metadata.width/height, so an orientation 5-8 photo arriving that way got a correctly rotated thumbnail and a database row describing it as landscape — the same aspect-ratio mismatch this PR set out to remove, just on the paths I had not grepped. (I searched for `metadata.width` and the v1 route aliases it to `meta`.) The animated guard was also wrong in two of the three generators. generateThumbnail and generateHeroImage never pass `animated: true`, so they already flatten a multi-frame source to its first frame — skipping .rotate() there protected an animation that was being discarded anyway, while leaving the output in raw orientation against swapped stored dimensions. Both now rotate unconditionally. generatePreviewImage keeps the guard, because it genuinely does open animated sources as animated and .rotate() would flatten them. That leaves one corner unsolved rather than papered over: a multi-frame source that also carries an orientation tag keeps its raw orientation in the preview while the thumbnail and stored dimensions describe the rotated one. GIF has no EXIF and animated WebP effectively never sets it, so it is a real gap but not a common one, and closing it means rotating frame by frame rather than quietly dropping the animation. Documented at the guard. * fix(images): add a recompute mode so existing libraries get corrected too (#1185) The orientation fix only helped new photos. A row affected by the bug has BOTH dimensions stored — just in the raw order — so the repair job's NULL filter could never reach exactly the rows that needed it. Worse, once their thumbnails regenerated rotated, those rows went from consistently-wrong (sideways image in a matching tile) to inconsistent: correct image, wrong-shaped tile. `recompute` widens the candidate set to every image row. Opt-in, because it re-reads every original. It also has to deal with the consequence for faces. Detection runs against the preview and stores boxes in ORIGINAL pixel space, scaled by `photo.width / previewMeta.width` (faceProcessor.js:220-224) — so a photo whose stored dimensions change has face data recorded against a coordinate system that no longer exists, and the overlays crop the wrong region. Photos whose dimensions actually change are requeued for scanning; ones that were already correct are not, or a routine repair would rescan the whole library. Rows with face_status NULL are left alone so installs that never enabled the feature don't start scanning because of a dimension repair. Writing the test for that last rule caught a real bug in it: the candidate query never selected photos.width/height, so `photo.width` was undefined and every row compared as changed. Both columns are selected now. * Revert "fix(images): add a recompute mode so existing libraries get corrected too (#1185)" This reverts commit cb771d08. Review round 3 found five problems, all of them in this addition rather than in the orientation fix itself, and one of them an own-goal: requeueing face scanning makes processPhotoFaces call ensurePreviewImage, which returns the CACHED pre-fix preview when it is still a valid image — so the rescan reads unrotated pixels and scales those boxes by the newly corrected dimensions. That is worse than leaving the data alone. The rest need work this PR should not be carrying: the dimension repair reads originals through resolvePhotoFilePath and plain sharp, so it does nothing on an S3 install and rejects RAW/DNG; recompute pulls archived rows whose originals were deleted on archive; orientation 2, 3 and 4 change the pixels without changing width or height, so a dimension-delta test never notices them; and the dimension write and the face invalidation are not atomic, so a failure between them leaves a row that no retry will ever requeue. Split out so it can be designed and reviewed on its own. The orientation fix — .rotate() in the three generators and orientedDimensions() at all eight ingest sites — is unaffected and stays. * fix(images): the watermarked rendition needs orienting too (#1185) A fourth generator with the same bug, found while reviewing the backfill that builds on this. watermarkService composites and re-encodes through its own sharp pipeline with no .rotate(), and gallery.js serves photos.watermark_path ahead of the original when branding watermarking is on — so on a watermarked gallery the sideways image is precisely what a guest sees. Two details this needed beyond the .rotate() itself: metadata() is read from a separate, unrotated handle. .rotate() does not change what metadata() reports — a 400x200 source tagged orientation 6 still reads 400x200 — and every use of those numbers here is positioning: watermark scale, font size, composite extent. They have to be the DISPLAYED dimensions or the mark is placed against the wrong axis, so they go through orientedDimensions. The composite offsets are floored. getPositionCoordinates derives from the SVG's estimated text extent and returns fractional pixels; sharp rejects a non-integer offset and applyWatermark catches its own error and returns the image unwatermarked. Landing on a whole pixel was luck, and changing the dimensions it is computed from ran out of it — the test surfaced a real "Expected integer for left but received 92.8". --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
2d98f5fa9f
commit
c18f54ede0
@@ -8,7 +8,7 @@ const { list, resolveExternalPath, getExternalMediaRoot } = require('../services
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const sharp = require('sharp');
|
||||
const logger = require('../utils/logger');
|
||||
const { generateThumbnail, extractCaptureDate } = require('../services/imageProcessor');
|
||||
const { generateThumbnail, extractCaptureDate, orientedDimensions } = require('../services/imageProcessor');
|
||||
const { isUniqueViolation } = require('../utils/dbErrors');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -202,8 +202,10 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
|
||||
let height = null;
|
||||
try {
|
||||
const metadata = await sharp(f.full).metadata();
|
||||
width = metadata.width || null;
|
||||
height = metadata.height || null;
|
||||
// Oriented, not raw: a portrait shot from a body that tags rather
|
||||
// than rotates reports landscape dimensions, and the grid would size
|
||||
// its tile from those (#1185).
|
||||
({ width, height } = orientedDimensions(metadata));
|
||||
} catch (dimErr) {
|
||||
logger.warn(`Could not extract dimensions for ${f.rel}: ${dimErr.message}`);
|
||||
}
|
||||
|
||||
@@ -170,13 +170,15 @@ router.post('/repair-dimensions', adminAuth, requirePermission('system.manage'),
|
||||
}
|
||||
|
||||
const metadata = await sharp(fullPath).metadata();
|
||||
// Oriented, not raw — see orientedDimensions (#1185).
|
||||
const dims = require('../services/imageProcessor').orientedDimensions(metadata);
|
||||
|
||||
if (metadata.width && metadata.height) {
|
||||
if (dims.width && dims.height) {
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({
|
||||
width: metadata.width,
|
||||
height: metadata.height
|
||||
width: dims.width,
|
||||
height: dims.height
|
||||
});
|
||||
successCount++;
|
||||
|
||||
|
||||
@@ -665,8 +665,8 @@ router.post(
|
||||
let height = null;
|
||||
try {
|
||||
const meta = await sharp(tempPath).metadata();
|
||||
width = meta.width || null;
|
||||
height = meta.height || null;
|
||||
// Oriented, not raw — see imageProcessor.orientedDimensions (#1185).
|
||||
({ width, height } = require('../../services/imageProcessor').orientedDimensions(meta));
|
||||
} catch { /* non-fatal */ }
|
||||
|
||||
let thumbRel = null;
|
||||
|
||||
@@ -112,8 +112,10 @@ async function processNewPhoto(filePath) {
|
||||
if (!isVideo) {
|
||||
try {
|
||||
const metadata = await sharp(filePath).metadata();
|
||||
if (metadata.width && metadata.height) {
|
||||
dimensions = { width: metadata.width, height: metadata.height };
|
||||
// Oriented, not raw — see imageProcessor.orientedDimensions (#1185).
|
||||
const dims = require('./imageProcessor').orientedDimensions(metadata);
|
||||
if (dims.width && dims.height) {
|
||||
dimensions = { width: dims.width, height: dims.height };
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(`Could not read image dimensions for ${filename}: ${err.message}`);
|
||||
|
||||
@@ -260,6 +260,22 @@ async function generateThumbnail(imagePath, options = {}) {
|
||||
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, which is
|
||||
// why this cannot be left to the client.
|
||||
//
|
||||
// Unconditional, unlike resizeToBox and generatePreviewImage. Those open
|
||||
// multi-frame sources with `animated: true` and must not rotate them,
|
||||
// because `.rotate()` flattens to the first frame. This one never passes
|
||||
// that option, so it already produces a still — guarding on `pages` here
|
||||
// would protect an animation that was being discarded anyway, and leave
|
||||
// the thumbnail in raw orientation while the stored dimensions describe
|
||||
// the rotated one.
|
||||
sharpInstance = sharpInstance.rotate();
|
||||
|
||||
// Strip EXIF/metadata from thumbnails (privacy: prevent GPS leak etc.)
|
||||
sharpInstance = sharpInstance.withMetadata(false);
|
||||
|
||||
@@ -338,6 +354,31 @@ async function isThumbnailValid(thumbnailPath) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -514,6 +555,11 @@ async function generateHeroImage(imagePath, options = {}) {
|
||||
failOn: 'none'
|
||||
});
|
||||
|
||||
// EXIF orientation, same reasoning as generateThumbnail (#1185) — and
|
||||
// unconditional for the same reason: no `animated: true` on the input, so
|
||||
// this output is a still whatever the source was.
|
||||
sharpInstance = sharpInstance.rotate();
|
||||
|
||||
// Strip EXIF/metadata from hero images (privacy: prevent GPS leak etc.)
|
||||
sharpInstance = sharpInstance.withMetadata(false);
|
||||
|
||||
@@ -735,6 +781,21 @@ async function generatePreviewImage(imagePath, options = {}) {
|
||||
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, so this is a real gap
|
||||
// rather than a common one, and closing it properly means rotating frame
|
||||
// by frame rather than dropping the animation.
|
||||
if (!isAnimated) {
|
||||
sharpInstance = sharpInstance.rotate();
|
||||
}
|
||||
|
||||
// Strip EXIF — same privacy reasoning as thumbnails/heroes.
|
||||
sharpInstance = sharpInstance.withMetadata(false);
|
||||
|
||||
@@ -1243,6 +1304,7 @@ async function resizeToBox(inputBuffer, box, options = {}) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
orientedDimensions,
|
||||
ensurePreviewImageAtWidth,
|
||||
ensureThumbnailAtWidth,
|
||||
thumbnailTierKeys,
|
||||
|
||||
@@ -174,11 +174,10 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
try {
|
||||
const sharp = require('sharp');
|
||||
const metadata = await sharp(proc.path).metadata();
|
||||
if (metadata.width && metadata.height) {
|
||||
imageMetadata = {
|
||||
width: metadata.width,
|
||||
height: metadata.height
|
||||
};
|
||||
// Oriented, not raw — see orientedDimensions (#1185).
|
||||
const dims = require('./imageProcessor').orientedDimensions(metadata);
|
||||
if (dims.width && dims.height) {
|
||||
imageMetadata = { width: dims.width, height: dims.height };
|
||||
}
|
||||
} catch (metadataError) {
|
||||
logger.warn(`Could not extract image dimensions for ${file.originalname}:`, metadataError.message);
|
||||
@@ -544,9 +543,11 @@ async function processPhoto(photoId) {
|
||||
try {
|
||||
const sharp = require('sharp');
|
||||
const metadata = await sharp(proc.path).metadata();
|
||||
if (metadata.width && metadata.height) {
|
||||
updateData.width = metadata.width;
|
||||
updateData.height = metadata.height;
|
||||
// Oriented, not raw — see orientedDimensions (#1185).
|
||||
const dims = require('./imageProcessor').orientedDimensions(metadata);
|
||||
if (dims.width && dims.height) {
|
||||
updateData.width = dims.width;
|
||||
updateData.height = dims.height;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`processPhoto: dimensions extraction failed for ${photoId}`, { error: e.message });
|
||||
|
||||
@@ -78,8 +78,8 @@ async function replacePhoto(existingPhoto, newFileTempPath, { originalFilename,
|
||||
try {
|
||||
try {
|
||||
const metadata = await sharp(proc.path).metadata();
|
||||
width = metadata.width || null;
|
||||
height = metadata.height || null;
|
||||
// Oriented, not raw — see imageProcessor.orientedDimensions (#1185).
|
||||
({ width, height } = require('./imageProcessor').orientedDimensions(metadata));
|
||||
} catch {
|
||||
// Non-image or corrupt
|
||||
}
|
||||
|
||||
@@ -110,8 +110,10 @@ async function processEvent(event, storage) {
|
||||
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 };
|
||||
// Oriented, not raw — see imageProcessor.orientedDimensions (#1185).
|
||||
const dims = require('./imageProcessor').orientedDimensions(metadata);
|
||||
if (dims.width && dims.height) {
|
||||
return { width: dims.width, height: dims.height };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const sharp = require('sharp');
|
||||
const { orientedDimensions } = require('./imageProcessor');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
@@ -115,9 +116,24 @@ class WatermarkService {
|
||||
}
|
||||
}
|
||||
|
||||
// Load the main image
|
||||
const image = sharp(imagePath);
|
||||
const metadata = await image.metadata();
|
||||
// Load the main image.
|
||||
//
|
||||
// .rotate() for the same reason as the other generators (#1185): sharp
|
||||
// decodes the pixels as stored, so an orientation-tagged photo would be
|
||||
// composited and re-encoded sideways — and gallery.js serves
|
||||
// watermark_path ahead of the original, so this is exactly what a guest
|
||||
// sees.
|
||||
const image = sharp(imagePath).rotate();
|
||||
|
||||
// Deliberately NOT `await image.metadata()`: .rotate() does not change
|
||||
// what metadata() reports — a 400x200 source tagged orientation 6 still
|
||||
// reads 400x200 there, even though the pipeline now emits 200x400. Every
|
||||
// use below is positioning (watermark scale, font size, composite
|
||||
// extent), so it has to be the DISPLAYED size or the mark lands against
|
||||
// the wrong axis.
|
||||
const rawMetadata = await sharp(imagePath).metadata();
|
||||
const oriented = orientedDimensions(rawMetadata);
|
||||
const metadata = { ...rawMetadata, width: oriented.width, height: oriented.height };
|
||||
|
||||
let watermarkBuffer;
|
||||
let watermarkMetadata;
|
||||
@@ -190,11 +206,17 @@ class WatermarkService {
|
||||
settings.position
|
||||
);
|
||||
|
||||
// Apply watermark with high quality output to preserve original image quality
|
||||
// Apply watermark with high quality output to preserve original image quality.
|
||||
//
|
||||
// Floored: getPositionCoordinates derives from the SVG's estimated text
|
||||
// extent, which is fractional, and sharp rejects a non-integer offset
|
||||
// outright — applyWatermark then catches its own error and silently
|
||||
// returns the unwatermarked original. Whether it lands on a whole pixel
|
||||
// was previously luck; nothing guaranteed it.
|
||||
let watermarkedImage = image.composite([{
|
||||
input: watermarkBuffer,
|
||||
top: position.top,
|
||||
left: position.left
|
||||
top: Math.max(0, Math.floor(position.top)),
|
||||
left: Math.max(0, Math.floor(position.left))
|
||||
}]);
|
||||
|
||||
// Preserve original format with high quality settings
|
||||
|
||||
Reference in New Issue
Block a user