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:
117
e2e/specs/06-export/exif-orientation.spec.ts
Normal file
117
e2e/specs/06-export/exif-orientation.spec.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user