diff --git a/CHANGELOG.md b/CHANGELOG.md index d2b7509..fa13bca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +# 1.1.1 - July 2 2026 + +* ***(FIX)*** EXIF-oriented images no longer double-rotate in EXIF-honoring + browsers (e.g. Safari rendering WebP; Chrome ignores the EXIF chunk in WebP, + which is why it appeared correct there). The `rotate` step bakes the EXIF + orientation into the pixels, but kept metadata still carried the original + `Orientation` tag, so browsers honoring it rotated the image a second time. + The outgoing tag is now neutralized (`Orientation: 1`) whenever orientation + compensation occurs. Optimized originals are intentionally unchanged (no + rotate step; their EXIF remains authoritative), so existing optimized caches + stay valid. ETags of processed EXIF-oriented images change accordingly + (win32 snapshot refreshed; Linux snapshot needs the usual CI-artifact + refresh). Regression coverage in `test/image-orientation.tests.ts` using the + new `test/files/angel.jpg` (orientation 6) fixture. + + # 1.1.0 - June 11 2026 * ***(ENHANCEMENT)*** Performance overhaul of the request hot path. Measured via diff --git a/lib/processor/steps/metadata.ts b/lib/processor/steps/metadata.ts index 62e0d87..33b6661 100644 --- a/lib/processor/steps/metadata.ts +++ b/lib/processor/steps/metadata.ts @@ -5,5 +5,12 @@ export default function metadata(context: any, stepInfo: any) { // defaults to pulling over meta data - context.sharp.withMetadata(); + context.metadataRequested = true; + + // if the rotate step has already baked the EXIF orientation into the + // pixels, reset the outgoing Orientation tag so EXIF-honoring browsers + // (e.g. Safari with WebP) don't rotate the image a second time + context.sharp.withMetadata( + context.orientationCorrected ? { orientation: 1 } : {} + ); } diff --git a/lib/processor/steps/rotate.ts b/lib/processor/steps/rotate.ts index 64503a1..17039b7 100644 --- a/lib/processor/steps/rotate.ts +++ b/lib/processor/steps/rotate.ts @@ -40,9 +40,15 @@ export default function rotate(context: any, stepInfo: any) { // otherwise do nothing } - // remove orientation now that it's been auto-corrected - // to avoid downloaded asset from being rotated again - // delete img.info.orientation; + // orientation is baked into the pixels from this point on (including + // when the net rotation is 0deg), so the EXIF Orientation tag must not + // survive into the output or EXIF-honoring browsers (e.g. Safari with + // WebP) will rotate the image a second time + context.orientationCorrected = true; + if (context.metadataRequested) { + // metadata step already ran; override the retained Orientation tag + context.sharp.withMetadata({ orientation: 1 }); + } } switch (degrees) { diff --git a/package-lock.json b/package-lock.json index 1eda910..d4c3bc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "image-steam", - "version": "1.0.1", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "image-steam", - "version": "1.0.1", + "version": "1.1.1", "license": "MIT", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index d58a703..881eddf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "image-steam", - "version": "1.1.0", + "version": "1.1.1", "description": "A simple, fast, and highly customizable realtime image manipulation web server built atop Node.js.", "type": "module", "main": "./dist/index.js", diff --git a/test/files/angel.jpg b/test/files/angel.jpg new file mode 100644 index 0000000..e0998af Binary files /dev/null and b/test/files/angel.jpg differ diff --git a/test/image-orientation.tests.ts b/test/image-orientation.tests.ts new file mode 100644 index 0000000..b88e70c --- /dev/null +++ b/test/image-orientation.tests.ts @@ -0,0 +1,114 @@ +import http from 'node:http'; +import { expect } from 'chai'; +import sharp from 'sharp'; +import isteam from '../lib/index.ts'; +import serverOptions from './image-server.config.ts'; + +// angel.jpg stores 4032x3024 (landscape) pixels with EXIF Orientation 6, +// meaning it must display as portrait (3024 wide, 4032 high). The processor +// bakes that rotation into the pixels, so the output must NOT retain an +// EXIF Orientation tag or EXIF-honoring browsers (e.g. Safari with WebP) +// rotate the image a second time. + +const WEBP_ACCEPT = 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8'; + +function getImage( + path: string, + headers: any = {} +): Promise<{ status: number; headers: any; body: Buffer }> { + return new Promise((resolve, reject) => { + http + .get( + { host: 'localhost', port: 13337, path, headers, agent: false }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => + resolve({ + status: res.statusCode as number, + headers: res.headers, + body: Buffer.concat(chunks), + }) + ); + } + ) + .on('error', reject); + }); +} + +describe('#Image Orientation (EXIF)', function () { + this.timeout(10000); + + let server: any; + + before(function () { + server = isteam.http.start(serverOptions); + }); + + after(function () { + isteam.http.stop(server); + }); + + it('bakes orientation into pixels and neutralizes EXIF Orientation (webp)', async function () { + const res = await getImage('/angel.jpg/:/rs=w:640?cache=false', { + accept: WEBP_ACCEPT, + }); + expect(res.status).to.equal(200); + expect(res.headers['content-type']).to.equal('image/webp'); + const meta = await sharp(res.body).metadata(); + expect(`${meta.width}x${meta.height}`).to.equal('640x853'); // portrait + expect(meta.orientation || 1).to.equal(1); // no stale rotation tag + }); + + it('bakes orientation into pixels and neutralizes EXIF Orientation (jpeg)', async function () { + const res = await getImage('/angel.jpg/:/rs=w:640?cache=false'); + expect(res.status).to.equal(200); + expect(res.headers['content-type']).to.equal('image/jpeg'); + const meta = await sharp(res.body).metadata(); + expect(`${meta.width}x${meta.height}`).to.equal('640x853'); // portrait + expect(meta.orientation || 1).to.equal(1); // no stale rotation tag + }); + + it('emits no EXIF Orientation when metadata step is disabled', async function () { + const res = await getImage('/angel.jpg/:/rs=w:640/md=e:false?cache=false'); + expect(res.status).to.equal(200); + const meta = await sharp(res.body).metadata(); + expect(`${meta.width}x${meta.height}`).to.equal('640x853'); // portrait + expect(meta.orientation).to.equal(undefined); // no metadata at all + }); + + it('composes explicit rotation with EXIF orientation and neutralizes the tag', async function () { + // orientation (90deg) + explicit 90deg = 180deg net, so pixel dimensions + // remain landscape and no further browser rotation may occur + const res = await getImage('/angel.jpg/:/rt=d:90/rs=w:640?cache=false'); + expect(res.status).to.equal(200); + const meta = await sharp(res.body).metadata(); + expect(`${meta.width}x${meta.height}`).to.equal('640x480'); // landscape + expect(meta.orientation || 1).to.equal(1); // no stale rotation tag + }); + + it('neutralizes EXIF Orientation when processed from cached optimized original', async function () { + // first request generates + caches the optimized original (which keeps + // its EXIF orientation), second request is processed from that artifact + await getImage('/angel.jpg/:/rs=w:200', { accept: WEBP_ACCEPT }); + const res = await getImage('/angel.jpg/:/rs=w:201', { + accept: WEBP_ACCEPT, + }); + expect(res.status).to.equal(200); + const meta = await sharp(res.body).metadata(); + expect(`${meta.width}x${meta.height}`).to.equal('201x268'); // portrait + expect(meta.orientation || 1).to.equal(1); // no stale rotation tag + }); + + it('optimized original retains the original EXIF orientation', async function () { + // the optimized original intentionally has no rotate step: its pixels + // stay as stored and the EXIF Orientation tag remains authoritative + const res = await getImage('/angel.jpg/:/rs=w:200?optimized=true', { + accept: WEBP_ACCEPT, + }); + expect(res.status).to.equal(200); + const meta = await sharp(res.body).metadata(); + expect(`${meta.width}x${meta.height}`).to.equal('2560x1920'); // as stored + expect(meta.orientation).to.equal(6); // EXIF still authoritative + }); +}); diff --git a/test/image-server.etags.linux.json b/test/image-server.etags.linux.json index ae4aabf..aaf840c 100644 --- a/test/image-server.etags.linux.json +++ b/test/image-server.etags.linux.json @@ -40,143 +40,143 @@ }, { "url": "http://localhost:13337/Portrait_2.jpg/:/?cache=false", - "etag": "621314706" + "etag": "3115768997" }, { "url": "http://localhost:13337/Portrait_2.jpg/:/rs=w:320?cache=false", - "etag": "2427400978" + "etag": "2136280612" }, { "url": "http://localhost:13337/Portrait_2.jpg/:/rt=d:180?cache=false", - "etag": "1716375556" + "etag": "1857124803" }, { "url": "http://localhost:13337/Portrait_2.jpg/:/rt=d:270?cache=false", - "etag": "95989407" + "etag": "3681856644" }, { "url": "http://localhost:13337/Portrait_2.jpg/:/rt=d:90?cache=false", - "etag": "3701879581" + "etag": "2011644855" }, { "url": "http://localhost:13337/Portrait_3.jpg/:/?cache=false", - "etag": "3220232200" + "etag": "820730609" }, { "url": "http://localhost:13337/Portrait_3.jpg/:/rs=w:320?cache=false", - "etag": "581170845" + "etag": "3843853455" }, { "url": "http://localhost:13337/Portrait_3.jpg/:/rt=d:180?cache=false", - "etag": "1724899874" + "etag": "3409134006" }, { "url": "http://localhost:13337/Portrait_3.jpg/:/rt=d:270?cache=false", - "etag": "274221414" + "etag": "1327153351" }, { "url": "http://localhost:13337/Portrait_3.jpg/:/rt=d:90?cache=false", - "etag": "2191119633" + "etag": "2227032747" }, { "url": "http://localhost:13337/Portrait_4.jpg/:/?cache=false", - "etag": "2774001998" + "etag": "2593351385" }, { "url": "http://localhost:13337/Portrait_4.jpg/:/rs=w:320?cache=false", - "etag": "1180735760" + "etag": "3529144161" }, { "url": "http://localhost:13337/Portrait_4.jpg/:/rt=d:180?cache=false", - "etag": "3626527791" + "etag": "171118021" }, { "url": "http://localhost:13337/Portrait_4.jpg/:/rt=d:270?cache=false", - "etag": "1438662276" + "etag": "27137040" }, { "url": "http://localhost:13337/Portrait_4.jpg/:/rt=d:90?cache=false", - "etag": "1296269542" + "etag": "341708444" }, { "url": "http://localhost:13337/Portrait_5.jpg/:/?cache=false", - "etag": "1745037" + "etag": "2553545379" }, { "url": "http://localhost:13337/Portrait_5.jpg/:/rs=w:320?cache=false", - "etag": "36419165" + "etag": "3180523683" }, { "url": "http://localhost:13337/Portrait_5.jpg/:/rt=d:180?cache=false", - "etag": "941668244" + "etag": "1607232194" }, { "url": "http://localhost:13337/Portrait_5.jpg/:/rt=d:270?cache=false", - "etag": "4031353629" + "etag": "1246991607" }, { "url": "http://localhost:13337/Portrait_5.jpg/:/rt=d:90?cache=false", - "etag": "33231874" + "etag": "248932946" }, { "url": "http://localhost:13337/Portrait_6.jpg/:/?cache=false", - "etag": "1588168154" + "etag": "3995632737" }, { "url": "http://localhost:13337/Portrait_6.jpg/:/rs=w:320?cache=false", - "etag": "2155865972" + "etag": "3742664729" }, { "url": "http://localhost:13337/Portrait_6.jpg/:/rt=d:180?cache=false", - "etag": "806439467" + "etag": "2384904547" }, { "url": "http://localhost:13337/Portrait_6.jpg/:/rt=d:270?cache=false", - "etag": "2759654619" + "etag": "2279988474" }, { "url": "http://localhost:13337/Portrait_6.jpg/:/rt=d:90?cache=false", - "etag": "254804682" + "etag": "1078464531" }, { "url": "http://localhost:13337/Portrait_7.jpg/:/?cache=false", - "etag": "2560695817" + "etag": "351518989" }, { "url": "http://localhost:13337/Portrait_7.jpg/:/rs=w:320?cache=false", - "etag": "3444163917" + "etag": "4256287240" }, { "url": "http://localhost:13337/Portrait_7.jpg/:/rt=d:180?cache=false", - "etag": "4156156995" + "etag": "2030099927" }, { "url": "http://localhost:13337/Portrait_7.jpg/:/rt=d:270?cache=false", - "etag": "161157245" + "etag": "218734802" }, { "url": "http://localhost:13337/Portrait_7.jpg/:/rt=d:90?cache=false", - "etag": "3822393549" + "etag": "728820580" }, { "url": "http://localhost:13337/Portrait_8.jpg/:/?cache=false", - "etag": "1577596038" + "etag": "486952070" }, { "url": "http://localhost:13337/Portrait_8.jpg/:/rs=w:320?cache=false", - "etag": "2921791791" + "etag": "3587897424" }, { "url": "http://localhost:13337/Portrait_8.jpg/:/rt=d:180?cache=false", - "etag": "1362729998" + "etag": "713511705" }, { "url": "http://localhost:13337/Portrait_8.jpg/:/rt=d:270?cache=false", - "etag": "1606490333" + "etag": "2708162512" }, { "url": "http://localhost:13337/Portrait_8.jpg/:/rt=d:90?cache=false", - "etag": "2052419191" + "etag": "977290520" }, { "url": "http://localhost:13337/UP_steam_loco.jpg/:/$colors=mn:false?cache=false" diff --git a/test/image-server.etags.win32.json b/test/image-server.etags.win32.json index be27fc1..b61f1d7 100644 --- a/test/image-server.etags.win32.json +++ b/test/image-server.etags.win32.json @@ -40,143 +40,143 @@ }, { "url": "http://localhost:13337/Portrait_2.jpg/:/?cache=false", - "etag": "621314706" + "etag": "3115768997" }, { "url": "http://localhost:13337/Portrait_2.jpg/:/rs=w:320?cache=false", - "etag": "2427400978" + "etag": "2136280612" }, { "url": "http://localhost:13337/Portrait_2.jpg/:/rt=d:180?cache=false", - "etag": "1716375556" + "etag": "1857124803" }, { "url": "http://localhost:13337/Portrait_2.jpg/:/rt=d:270?cache=false", - "etag": "95989407" + "etag": "3681856644" }, { "url": "http://localhost:13337/Portrait_2.jpg/:/rt=d:90?cache=false", - "etag": "3701879581" + "etag": "2011644855" }, { "url": "http://localhost:13337/Portrait_3.jpg/:/?cache=false", - "etag": "3220232200" + "etag": "820730609" }, { "url": "http://localhost:13337/Portrait_3.jpg/:/rs=w:320?cache=false", - "etag": "581170845" + "etag": "3843853455" }, { "url": "http://localhost:13337/Portrait_3.jpg/:/rt=d:180?cache=false", - "etag": "1724899874" + "etag": "3409134006" }, { "url": "http://localhost:13337/Portrait_3.jpg/:/rt=d:270?cache=false", - "etag": "274221414" + "etag": "1327153351" }, { "url": "http://localhost:13337/Portrait_3.jpg/:/rt=d:90?cache=false", - "etag": "2191119633" + "etag": "2227032747" }, { "url": "http://localhost:13337/Portrait_4.jpg/:/?cache=false", - "etag": "2774001998" + "etag": "2593351385" }, { "url": "http://localhost:13337/Portrait_4.jpg/:/rs=w:320?cache=false", - "etag": "1180735760" + "etag": "3529144161" }, { "url": "http://localhost:13337/Portrait_4.jpg/:/rt=d:180?cache=false", - "etag": "3626527791" + "etag": "171118021" }, { "url": "http://localhost:13337/Portrait_4.jpg/:/rt=d:270?cache=false", - "etag": "1438662276" + "etag": "27137040" }, { "url": "http://localhost:13337/Portrait_4.jpg/:/rt=d:90?cache=false", - "etag": "1296269542" + "etag": "341708444" }, { "url": "http://localhost:13337/Portrait_5.jpg/:/?cache=false", - "etag": "1745037" + "etag": "2553545379" }, { "url": "http://localhost:13337/Portrait_5.jpg/:/rs=w:320?cache=false", - "etag": "36419165" + "etag": "3180523683" }, { "url": "http://localhost:13337/Portrait_5.jpg/:/rt=d:180?cache=false", - "etag": "941668244" + "etag": "1607232194" }, { "url": "http://localhost:13337/Portrait_5.jpg/:/rt=d:270?cache=false", - "etag": "4031353629" + "etag": "1246991607" }, { "url": "http://localhost:13337/Portrait_5.jpg/:/rt=d:90?cache=false", - "etag": "33231874" + "etag": "248932946" }, { "url": "http://localhost:13337/Portrait_6.jpg/:/?cache=false", - "etag": "1588168154" + "etag": "3995632737" }, { "url": "http://localhost:13337/Portrait_6.jpg/:/rs=w:320?cache=false", - "etag": "2155865972" + "etag": "3742664729" }, { "url": "http://localhost:13337/Portrait_6.jpg/:/rt=d:180?cache=false", - "etag": "806439467" + "etag": "2384904547" }, { "url": "http://localhost:13337/Portrait_6.jpg/:/rt=d:270?cache=false", - "etag": "2759654619" + "etag": "2279988474" }, { "url": "http://localhost:13337/Portrait_6.jpg/:/rt=d:90?cache=false", - "etag": "254804682" + "etag": "1078464531" }, { "url": "http://localhost:13337/Portrait_7.jpg/:/?cache=false", - "etag": "2560695817" + "etag": "351518989" }, { "url": "http://localhost:13337/Portrait_7.jpg/:/rs=w:320?cache=false", - "etag": "3444163917" + "etag": "4256287240" }, { "url": "http://localhost:13337/Portrait_7.jpg/:/rt=d:180?cache=false", - "etag": "4156156995" + "etag": "2030099927" }, { "url": "http://localhost:13337/Portrait_7.jpg/:/rt=d:270?cache=false", - "etag": "161157245" + "etag": "218734802" }, { "url": "http://localhost:13337/Portrait_7.jpg/:/rt=d:90?cache=false", - "etag": "3822393549" + "etag": "728820580" }, { "url": "http://localhost:13337/Portrait_8.jpg/:/?cache=false", - "etag": "1577596038" + "etag": "486952070" }, { "url": "http://localhost:13337/Portrait_8.jpg/:/rs=w:320?cache=false", - "etag": "2921791791" + "etag": "3587897424" }, { "url": "http://localhost:13337/Portrait_8.jpg/:/rt=d:180?cache=false", - "etag": "1362729998" + "etag": "713511705" }, { "url": "http://localhost:13337/Portrait_8.jpg/:/rt=d:270?cache=false", - "etag": "1606490333" + "etag": "2708162512" }, { "url": "http://localhost:13337/Portrait_8.jpg/:/rt=d:90?cache=false", - "etag": "2052419191" + "etag": "977290520" }, { "url": "http://localhost:13337/UP_steam_loco.jpg/:/$colors=mn:false?cache=false"