Files
picpeak/backend/__tests__/services/imageProcessorOrientation.test.js
T
Paul Nothaft 5559cd333d 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 <paul@MacStudio-von-Paul.local>
2026-08-26 21:13:08 +02:00

160 lines
6.5 KiB
JavaScript

/**
* EXIF orientation in the gallery-facing generators (#1185).
*
* sharp decodes the pixels as stored, not as displayed. A photo whose
* Orientation tag is not 1 — routine for portrait shots on bodies that tag
* rather than rotate the sensor data — therefore resizes from the raw frame
* and comes out sideways. The generators then call `.withMetadata(false)`,
* which strips the tag from the output, so the browser has no hint left to
* correct it either: nothing downstream can recover it.
*
* The download path (`resizeToBox`) always got this right. These three did
* not, which is why the same photo looked correct on download and rotated in
* the gallery.
*
* Every assertion here fails on the unfixed generators.
*/
const fs = require('fs').promises;
const path = require('path');
const os = require('os');
const sharp = require('sharp');
// imageProcessor reads its thumbnail settings from app_settings, so without a
// db the require() alone hangs the run. Same shape the other generator tests
// use (ensureHeroImage.external.test.js).
jest.mock('../../src/database/db', () => {
const api = (table) => {
if (table === 'app_settings') {
return { where: () => ({ whereIn: () => [], first: async () => null }), whereIn: async () => [] };
}
if (table === 'events') return { where: () => ({ first: async () => null }) };
if (table === 'photos') return { where: () => ({ update: async () => 1, first: async () => null }) };
return { where: () => ({ first: async () => null }) };
};
return { db: api };
});
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
const storageModule = require('../../src/services/storage');
describe('EXIF orientation in thumbnails, heroes and previews (#1185)', () => {
let tmpDir;
let imageProcessor;
let landscapeTaggedPortrait;
// 400x200 as stored, Orientation 6 (90° CW) — so it DISPLAYS as 200x400.
// This is exactly the shape the reporter's Sony bodies produce: the sensor
// data is landscape and the tag carries the rotation.
const W = 400;
const H = 200;
let storageRoot;
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-orient-'));
storageRoot = path.join(tmpDir, 'storage');
await fs.mkdir(storageRoot, { recursive: true });
process.env.STORAGE_PATH = storageRoot;
const storage = new LocalFsStorage({ root: storageRoot });
await storage.init();
storageModule.setStorageForTesting(storage);
landscapeTaggedPortrait = path.join(tmpDir, 'portrait-tagged.jpg');
await sharp({
create: { width: W, height: H, channels: 3, background: { r: 120, g: 80, b: 40 } },
})
.withMetadata({ orientation: 6 })
.jpeg()
.toFile(landscapeTaggedPortrait);
delete require.cache[require.resolve('../../src/services/imageProcessor')];
imageProcessor = require('../../src/services/imageProcessor');
}, 60000);
afterAll(async () => {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
test('the fixture really is stored landscape with a rotation tag', async () => {
const m = await sharp(landscapeTaggedPortrait).metadata();
expect(m.width).toBe(W);
expect(m.height).toBe(H);
expect(m.orientation).toBe(6);
});
test('orientedDimensions reports what a viewer sees, not what is stored', async () => {
const m = await sharp(landscapeTaggedPortrait).metadata();
// Swapped: this is what the grid needs to size a tile, and what the
// database should hold.
expect(imageProcessor.orientedDimensions(m)).toEqual({ width: H, height: W });
});
test('orientedDimensions leaves an untagged image alone', async () => {
const plain = path.join(tmpDir, 'plain.jpg');
await sharp({ create: { width: W, height: H, channels: 3, background: { r: 1, g: 2, b: 3 } } })
.jpeg().toFile(plain);
const m = await sharp(plain).metadata();
expect(imageProcessor.orientedDimensions(m)).toEqual({ width: W, height: H });
});
test('orientedDimensions survives metadata it cannot use', () => {
expect(imageProcessor.orientedDimensions(null)).toEqual({ width: null, height: null });
expect(imageProcessor.orientedDimensions({})).toEqual({ width: null, height: null });
});
test('the thumbnail comes out portrait, not sideways', async () => {
const rel = await imageProcessor.generateThumbnail(landscapeTaggedPortrait, {
outputBasename: 'orient-thumb.jpg',
regenerate: true,
});
expect(rel).toBeTruthy();
const out = await sharp(path.join(storageRoot, rel)).metadata();
// Unfixed, this came back wider than tall — the raw frame, resized.
expect(out.height).toBeGreaterThan(out.width);
});
test('the preview tier comes out portrait too', async () => {
const rel = await imageProcessor.generatePreviewImage(landscapeTaggedPortrait, {
outputBasename: 'orient-preview.jpg',
});
expect(rel).toBeTruthy();
const out = await sharp(path.join(storageRoot, rel)).metadata();
expect(out.height).toBeGreaterThan(out.width);
});
test('the watermarked rendition is oriented too', async () => {
// gallery.js serves photos.watermark_path ahead of the original when
// branding watermarking is on, so this is the rendition a guest actually
// sees — and it went through its own sharp pipeline that nobody had
// rotated.
const watermarkService = require('../../src/services/watermarkService');
const buf = await watermarkService.applyWatermark(landscapeTaggedPortrait, {
enabled: true, position: 'bottom-right', opacity: 50, size: 15,
// companyName, not text — the SVG branch reads this one, and without it
// the service falls through without compositing anything.
companyName: 'PicPeak',
});
expect(Buffer.isBuffer(buf)).toBe(true);
const out = await sharp(buf).metadata();
// 400x200 stored, tagged 6 — so the watermarked output must be portrait.
expect(out.height).toBeGreaterThan(out.width);
});
test('the orientation tag is gone from the output, so nothing double-rotates', async () => {
// The pixels are corrected now, so a surviving tag would make a viewer
// rotate an already-rotated image. withMetadata(false) strips it; this
// pins that the two changes agree.
const rel = await imageProcessor.generateThumbnail(landscapeTaggedPortrait, {
outputBasename: 'orient-thumb-tag.jpg',
regenerate: true,
});
const out = await sharp(path.join(storageRoot, rel)).metadata();
expect(out.orientation === undefined || out.orientation === 1).toBe(true);
});
});