Files
picpeak/backend/__tests__/services/photoProcessor.processPhoto.test.js
T
Paul Nothaft c18f54ede0 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 <paul@MacStudio-von-Paul.local>
2026-08-26 20:55:54 +02:00

273 lines
9.2 KiB
JavaScript

/**
* Unit tests for photoProcessor.processPhoto — the worker-mode entry
* point that runs after a row has been claimed by the background
* processor. Mocks every external dependency and validates the
* happy-path DB updates and side-effect ordering.
*
* jest.mock factories are evaluated before any local variables exist,
* so collaborators are kept inside the mock factories themselves and
* the test reaches into them via require() once they're set up.
*/
const path = require('path');
jest.mock('../../src/database/db', () => {
const recorded = { whereCalls: [], updateCalls: [] };
let pendingWhere = null;
const photosState = { row: null };
const eventsState = { row: null };
function makePhotoQuery() {
return {
where(args) {
pendingWhere = args;
recorded.whereCalls.push(args);
return this;
},
async first() {
return photosState.row;
},
async update(data) {
recorded.updateCalls.push({ where: pendingWhere, data });
return 1;
},
};
}
function makeEventsQuery() {
return {
where() {
return this;
},
async first() {
return eventsState.row;
},
};
}
function dbFn(table) {
if (table === 'photos') return makePhotoQuery();
if (table === 'events') return makeEventsQuery();
throw new Error(`Unexpected table: ${table}`);
}
dbFn.client = { config: { client: 'pg' } };
return {
db: dbFn,
__setPhoto: (row) => { photosState.row = row; },
__setEvent: (row) => { eventsState.row = row; },
__reset: () => {
recorded.whereCalls = [];
recorded.updateCalls = [];
photosState.row = null;
eventsState.row = null;
},
__recorded: () => recorded,
};
});
jest.mock('../../src/services/imageProcessor', () => {
const mockGenerateThumbnail = jest.fn();
const mockExtractCaptureDate = jest.fn();
return {
generateThumbnail: mockGenerateThumbnail,
generateVideoPlaceholder: jest.fn(async (filename) => `thumbnails/thumb_${filename.replace(/\.[^.]+$/, '')}.jpg`),
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)}`)
),
// Pass-through for ordinary (non-RAW) images: returns the path unchanged
// with a no-op cleanup, matching the real helper's behaviour for jpg/png.
withProcessableImage: jest.fn(async (localPath) => ({
path: localPath,
outputBasename: undefined,
cleanup: () => {},
})),
};
});
jest.mock('../../src/services/videoProcessor', () => ({
processUploadedVideo: jest.fn(),
extractVideoMetadata: jest.fn(),
isVideoMimeType: (mime) => typeof mime === 'string' && mime.startsWith('video/'),
}));
jest.mock('../../src/services/storage', () => ({ getStorage: jest.fn() }));
jest.mock('../../src/services/photoResolver', () => ({
resolvePhotoStorageKey: jest.fn(
(event, photo) => `events/active/${event.slug}/${photo.filename}`
),
}));
jest.mock('../../src/utils/filenameSanitizer', () => ({
generatePhotoFilename: jest.fn(() => 'whatever.jpg'),
}));
jest.mock('../../src/services/watermarkGeneratorService', () => ({
generateForPhoto: jest.fn(() => Promise.resolve()),
}));
jest.mock('../../src/services/webhookService', () => ({
fire: jest.fn(() => Promise.resolve()),
}));
jest.mock('../../src/utils/logger', () => ({
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
// Stub sharp so we don't actually read any image off disk.
jest.mock('sharp', () => {
const mock = jest.fn(() => ({
metadata: jest.fn(async () => ({ width: 1920, height: 1080 })),
}));
return mock;
});
const dbModule = require('../../src/database/db');
const imageProcessor = require('../../src/services/imageProcessor');
const videoProcessor = require('../../src/services/videoProcessor');
const watermarkService = require('../../src/services/watermarkGeneratorService');
const webhookService = require('../../src/services/webhookService');
beforeEach(() => {
dbModule.__reset();
jest.clearAllMocks();
});
describe('photoProcessor.processPhoto', () => {
it('marks an image complete with thumbnail and dimensions', async () => {
dbModule.__setPhoto({
id: 101,
event_id: 5,
filename: 'wedding-001.jpg',
original_filename: 'IMG_0001.jpg',
mime_type: 'image/jpeg',
media_type: 'image',
size_bytes: 12345,
captured_at: null,
processing_status: 'processing',
});
dbModule.__setEvent({ id: 5, slug: 'wedding', event_name: 'Wedding' });
imageProcessor.extractCaptureDate.mockResolvedValueOnce('2026-04-25T12:00:00Z');
imageProcessor.generateThumbnail.mockResolvedValueOnce('thumbnails/thumb_wedding-001.jpg');
const { processPhoto } = require('../../src/services/photoProcessor');
await processPhoto(101);
const finalUpdate = dbModule.__recorded().updateCalls.pop();
expect(finalUpdate.data.processing_status).toBe('complete');
expect(finalUpdate.data.processing_error).toBeNull();
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-001.jpg');
expect(finalUpdate.data.width).toBe(1920);
expect(finalUpdate.data.height).toBe(1080);
expect(finalUpdate.data.captured_at).toBe('2026-04-25T12:00:00Z');
expect(watermarkService.generateForPhoto).toHaveBeenCalledWith(101);
expect(webhookService.fire).toHaveBeenCalledWith(
'photo.uploaded',
expect.objectContaining({
event: expect.objectContaining({ slug: 'wedding' }),
photo: expect.objectContaining({ id: 101, filename: 'wedding-001.jpg' }),
})
);
});
it('handles videos with ffmpeg metadata path', async () => {
dbModule.__setPhoto({
id: 202,
event_id: 9,
filename: 'wedding-video-001.mp4',
original_filename: 'movie.mp4',
mime_type: 'video/mp4',
media_type: 'video',
size_bytes: 99999,
captured_at: null,
});
dbModule.__setEvent({ id: 9, slug: 'wedding', event_name: 'Wedding' });
videoProcessor.processUploadedVideo.mockResolvedValueOnce({
thumbnailKey: 'thumbnails/thumb_wedding-video-001.jpg',
metadata: {
duration: 12.5,
videoCodec: 'h264',
audioCodec: 'aac',
width: 1280,
height: 720,
},
});
const { processPhoto } = require('../../src/services/photoProcessor');
await processPhoto(202);
const finalUpdate = dbModule.__recorded().updateCalls.pop();
expect(finalUpdate.data.processing_status).toBe('complete');
expect(finalUpdate.data.duration).toBe(12.5);
expect(finalUpdate.data.video_codec).toBe('h264');
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-video-001.jpg');
// Watermark queue is image-only.
expect(watermarkService.generateForPhoto).not.toHaveBeenCalled();
});
it('keeps a video complete with a placeholder thumbnail when ffmpeg fails', async () => {
dbModule.__setPhoto({
id: 203,
event_id: 9,
filename: 'drone-clip.mp4',
original_filename: 'drone.mp4',
mime_type: 'video/mp4',
media_type: 'video',
size_bytes: 12345,
captured_at: null,
});
dbModule.__setEvent({ id: 9, slug: 'wedding', event_name: 'Wedding' });
// ffmpeg thumbnail pipeline throws (e.g. unsupported pixel format)…
videoProcessor.processUploadedVideo.mockRejectedValueOnce(new Error('ffmpeg exited with code 1'));
// …but a plain probe still works.
videoProcessor.extractVideoMetadata.mockResolvedValueOnce({
duration: 42,
videoCodec: 'hevc',
audioCodec: 'aac',
width: 3840,
height: 2160,
});
const { processPhoto } = require('../../src/services/photoProcessor');
await processPhoto(203);
const finalUpdate = dbModule.__recorded().updateCalls.pop();
// The row must complete — 'failed' rows are invisible to guests.
expect(finalUpdate.data.processing_status).toBe('complete');
// Placeholder instead of NULL: a completed video without thumbnail would
// make the grid fetch the original video file for the tile (#845 review).
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_drone-clip.jpg');
expect(imageProcessor.generateVideoPlaceholder).toHaveBeenCalledWith('drone-clip.mp4');
expect(finalUpdate.data.duration).toBe(42);
expect(finalUpdate.data.video_codec).toBe('hevc');
});
it('throws when the photo row no longer exists', async () => {
dbModule.__setPhoto(null);
dbModule.__setEvent({ id: 1 });
const { processPhoto } = require('../../src/services/photoProcessor');
await expect(processPhoto(999)).rejects.toThrow(/Photo 999 not found/);
});
});
void path; // referenced indirectly via mocks