Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 8 additions & 1 deletion lib/processor/steps/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}
);
}
12 changes: 9 additions & 3 deletions lib/processor/steps/rotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Binary file added test/files/angel.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
114 changes: 114 additions & 0 deletions test/image-orientation.tests.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
70 changes: 35 additions & 35 deletions test/image-server.etags.linux.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading