/** * Regression guard — EXIF orientation must be applied when generating derivatives. * * Phones do not rotate sensor data. They shoot in the sensor's native landscape and record * how the camera was held in an EXIF `Orientation` tag. `image`'s `decode()` returns the raw * pixels and ignores that tag, and the JPEG re-encode writes no EXIF at all — so every * portrait photo was stored SIDEWAYS in the 800px feed preview, the 2048px diashow display * and the keepsake, while "Original anzeigen" still rendered it upright (the original keeps * its tag). That asymmetry is why it reads as a viewer bug instead of a pipeline one. * * The fixture is 40x20 landscape pixels tagged Orientation=6 ("rotate 90° CW to display"), * so a correctly-processed derivative is PORTRAIT (20x40). Asserting on the aspect ratio * rather than the bytes keeps this robust across encoder changes. */ import { test, expect } from '../../fixtures/test'; import { uploadRaw } from '../../helpers/upload-client'; import { BASE } from '../../helpers/env'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; const EXIF_FIXTURE = join(process.cwd(), 'fixtures', 'media', 'portrait-exif6.jpg'); /** * Read a baseline/progressive JPEG's pixel dimensions from its SOF marker. * Avoids pulling an image dependency into the suite for one assertion. */ function jpegSize(buf: Buffer): { width: number; height: number } { let i = 2; // skip SOI while (i < buf.length) { if (buf[i] !== 0xff) { i++; continue; } const marker = buf[i + 1]; // SOF0..SOF15, excluding DHT (c4), JPGA (c8) and DAC (cc) 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('Upload — EXIF orientation', () => { test('a rotated photo is upright in the preview and the display derivative', async ({ guest, db, }) => { const g = await guest('SidewaysShooter'); const res = await uploadRaw(g.jwt, readFileSync(EXIF_FIXTURE), { filename: 'portrait-exif6.jpg', contentType: 'image/jpeg', caption: 'hochkant', }); expect(res.status).toBe(201); const { id } = (await res.json()) as { id: string }; // Sanity: the SOURCE really is stored landscape with the tag, otherwise this test // could pass against a pipeline that does nothing. const source = jpegSize(readFileSync(EXIF_FIXTURE)); expect(source.width).toBeGreaterThan(source.height); await expect .poll(() => db.compressionStatus(id), { timeout: 30_000, intervals: [250] }) .toBe('done'); for (const variant of ['preview', 'display'] as const) { const r = await fetch(`${BASE}/api/v1/upload/${id}/${variant}`, { headers: { Authorization: `Bearer ${g.jwt}` }, }); expect(r.status, `${variant} must be served`).toBe(200); const { width, height } = jpegSize(Buffer.from(await r.arrayBuffer())); expect( height, `${variant} must be portrait (${width}x${height}) — EXIF orientation was not applied` ).toBeGreaterThan(width); } }); });