fix(export): apply EXIF orientation in the keepsake too

Round 1 fixed EXIF orientation in the compression worker, which corrected the live
app — feed preview and diashow display. The export worker was missed, and it does
not reuse those derivatives: it re-decodes the originals itself with `image::open`,
which ignores the orientation tag, then re-encodes to JPEG, which drops the tag —
so the viewer has no way to recover it.

The damage was oddly shaped, which is exactly why it reads as a viewer bug:

  Gallery.zip originals              correct  (byte-copied, EXIF intact)
  Memories viewer grid thumbnails    SIDEWAYS (always)
  Memories viewer full image >5 MB   SIDEWAYS (re-encoded at 2000px)
  Memories viewer full image ≤5 MB   correct  (streamed byte-for-byte)

So in the keepsake people actually keep, every portrait photo in the grid was on
its side, and clicking through silently "fixed" small photos but not large ones.

Rather than paste the decoder dance a third time, extract `services::imaging::
decode_oriented` and route both workers through it, so there is exactly one way to
turn a file on disk into a DynamicImage. It carries a second invariant that had
also drifted: `image::open` applies NO decode limits, so the export path was
decoding arbitrary user-supplied images unbounded — the decompression-bomb cap
existed only in the compression worker. Both now come as a pair, which is the
point of having one function.

Not done: switching export to consume the existing `display` derivative. It would
fix orientation and drop a redundant full-resolution decode per photo, but it
would also replace the pristine ≤5 MB originals in the keepsake with 2048px
re-encodes — a real quality regression in the one artefact people keep forever.

Test uploads the round-1 fixture (40x20 landscape tagged Orientation=6), runs a
real export, pulls the thumbnail out of Memories.zip and asserts it came back
portrait — with a sanity check that the source really is stored landscape, so the
test can't pass against a pipeline that does nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-28 20:46:38 +02:00
parent d6fdc13da9
commit 3c984e2932
5 changed files with 185 additions and 36 deletions

View File

@@ -0,0 +1,117 @@
/**
* Regression guard — the keepsake must not be sideways.
*
* Round 1 taught the compression worker to apply EXIF orientation, which fixed the live app
* (feed preview + diashow display). The export worker was missed: it does NOT reuse those
* derivatives — it re-decodes the originals itself with `image::open`, which ignores the
* orientation tag — and then re-encodes to JPEG, which drops the tag, so the viewer has no
* way to recover it.
*
* The resulting damage was oddly shaped, which is what made it read as a viewer bug:
* - Gallery.zip originals → correct (byte-copied, EXIF intact)
* - Memories viewer grid thumbnails → ALWAYS sideways
* - Memories viewer full image >5 MB → sideways (re-encoded at 2000px)
* - Memories viewer full image ≤5 MB → correct (streamed byte-for-byte)
*
* So clicking a small photo silently "fixed" it and a large one didn't. This pins the
* thumbnail, which is the path every photo takes.
*/
import { test, expect } from '../../fixtures/test';
import { uploadRaw } from '../../helpers/upload-client';
import { BASE } from '../../helpers/env';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// 40x20 landscape pixels tagged Orientation=6 ("rotate 90° CW to display"), so anything
// that honours the tag emits a PORTRAIT derivative.
const EXIF_FIXTURE = join(process.cwd(), 'fixtures', 'media', 'portrait-exif6.jpg');
/** Pixel dimensions from a JPEG's SOF marker — avoids an image dep for one assertion. */
function jpegSize(buf: Buffer): { width: number; height: number } {
let i = 2;
while (i < buf.length) {
if (buf[i] !== 0xff) {
i++;
continue;
}
const marker = buf[i + 1];
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
}
i += 2 + buf.readUInt16BE(i + 2);
}
throw new Error('no SOF marker found — not a JPEG?');
}
test.describe('Export — EXIF orientation in the keepsake', () => {
test('the Memories viewer thumbnail of a rotated photo is upright', async ({ host, db }) => {
test.setTimeout(90_000);
const bearer = { Authorization: `Bearer ${host.jwt}` };
const src = readFileSync(EXIF_FIXTURE);
// Sanity: the SOURCE really is stored landscape, or this test proves nothing.
const srcSize = jpegSize(src);
expect(srcSize.width).toBeGreaterThan(srcSize.height);
const up = await uploadRaw(host.jwt, src, {
filename: 'hochkant.jpg',
contentType: 'image/jpeg',
caption: 'hochkant',
});
expect(up.status).toBe(201);
const { id } = (await up.json()) as { id: string };
await expect.poll(() => db.compressionStatus(id), { timeout: 30_000 }).toBe('done');
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
method: 'POST',
headers: bearer,
});
expect(rel.status).toBe(204);
await expect
.poll(
async () => {
const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
return (await res.json()).html?.status;
},
{ timeout: 60_000, intervals: [500] }
)
.toBe('done');
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
method: 'POST',
headers: bearer,
});
const { ticket } = (await ticketRes.json()) as { ticket: string };
const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
expect(dl.status).toBe(200);
const dir = mkdtempSync(join(tmpdir(), 'eventsnap-exif-'));
try {
const zipPath = join(dir, 'Memories.zip');
writeFileSync(zipPath, Buffer.from(await dl.arrayBuffer()));
const entries = execFileSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' })
.split('\n')
.filter(Boolean);
const thumbEntry = entries.find((e) => e.includes(`${id}_thumb`));
expect(thumbEntry, `no thumbnail for ${id} in Memories.zip`).toBeTruthy();
// `-p` streams the entry to stdout. Extracting to disk instead fails with EACCES:
// the archive preserves the container's file mode, which the test user can't read.
const thumb = execFileSync('unzip', ['-p', zipPath, thumbEntry!], {
maxBuffer: 64 * 1024 * 1024,
});
const { width, height } = jpegSize(thumb);
expect(
height,
`the keepsake grid thumbnail is ${width}x${height} — EXIF orientation was not applied`
).toBeGreaterThan(width);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});