fix(images): respect EXIF orientation in thumbnails, heroes, previews and watermarks (#1185) (#1202)

Stable twin of #1194.

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,
which is why the same photo looked correct on download and rotated in the
gallery.

watermarkService had it too, and it is the one a guest actually sees:
gallery.js serves photos.watermark_path ahead of the original when branding
watermarking is on. Two details there — metadata() is read from a separate
unrotated handle, because .rotate() does not change what it reports and every
use of those numbers is positioning; and the composite offsets are floored,
because getPositionCoordinates returns fractional pixels, sharp rejects them,
and applyWatermark catches its own error and silently returns the image
unwatermarked.

The rotate is unconditional in the thumbnail and hero generators — neither
passes `animated: true`, so both already flatten a multi-frame source and
guarding there would protect an animation that was being discarded anyway.
generatePreviewImage keeps the guard, since it genuinely does preserve
animation.

photos.width/height were stored from sharp's metadata, which reports pixels as
STORED, not displayed. For orientation 5-8 those are swapped, so a portrait
photo landed in the database as landscape and masonry sized its tile with the
wrong aspect ratio on top of the image being unrotated. A shared
orientedDimensions() helper now does the conversion at all eight image ingest
sites. The video path is deliberately untouched: its dimensions come from
ffprobe, where EXIF orientation does not apply.

Existing rows keep their pre-rotation dimensions until reprocessed; the backfill
for those is #1199 on main and is not ported here yet.

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-26 21:13:08 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent ac7ef266dc
commit 5559cd333d
11 changed files with 274 additions and 25 deletions
@@ -72,6 +72,15 @@ jest.mock('../../src/services/imageProcessor', () => {
return {
generateThumbnail: mockGenerateThumbnail,
extractCaptureDate: mockExtractCaptureDate,
// processPhoto routes stored dimensions through this to get the displayed
// ones (#1185). Mirrored rather than requireActual'd, because pulling the
// real module in here would drag its database dependency into the mock
// factory. Kept faithful to imageProcessor.orientedDimensions.
orientedDimensions: jest.fn((m) => {
if (!m || !m.width || !m.height) return { width: null, height: null };
const swap = m.orientation >= 5 && m.orientation <= 8;
return { width: swap ? m.height : m.width, height: swap ? m.width : m.height };
}),
withLocalCopy: jest.fn(async (key, fn) =>
fn(`/tmp/local-copy-${require('path').basename(key)}`)
),