diff --git a/.changeset/images-local-exif-orientation.md b/.changeset/images-local-exif-orientation.md new file mode 100644 index 00000000000..61977e64447 --- /dev/null +++ b/.changeset/images-local-exif-orientation.md @@ -0,0 +1,5 @@ +--- +"miniflare": patch +--- + +Fix the local Images binding and `cf.image` transforms ignoring EXIF orientation. Previously, photos stored with an EXIF orientation flag (e.g. phone portrait photos, stored as landscape pixels plus a rotation flag) came back sideways from local transforms, while the production Images binding auto-orients them. Local dev now bakes the EXIF rotation into the pixels before applying transforms, matching production behavior. diff --git a/packages/miniflare/src/plugins/images/fetcher.ts b/packages/miniflare/src/plugins/images/fetcher.ts index 566647e1a7d..f4db42f10b5 100644 --- a/packages/miniflare/src/plugins/images/fetcher.ts +++ b/packages/miniflare/src/plugins/images/fetcher.ts @@ -61,12 +61,12 @@ export async function imagesLocalFetcher(request: Request): Promise { ); } - const transformer = sharp(await body.arrayBuffer(), {}); + const source = await body.arrayBuffer(); const url = new URL(request.url); if (url.pathname == "/info") { - return runInfo(transformer); + return runInfo(sharp(source, {})); } else { const badTransformsResponse = errorResponse( 400, @@ -96,7 +96,14 @@ export async function imagesLocalFetcher(request: Request): Promise { ); } - return runTransform(transformer, transforms, outputFormat); + // Production applies EXIF orientation before any transforms; + // autoOrient bakes the rotation into the pixels at decode so + // EXIF-rotated photos (e.g. phone portraits) come out upright. + return runTransform( + sharp(source, { autoOrient: true }), + transforms, + outputFormat + ); } catch { return badTransformsResponse; } @@ -401,7 +408,7 @@ export async function cfImageLocalFetcher(request: Request): Promise { } if (options.format === "json") { - const jsonTransformer = sharp(source); + const jsonTransformer = sharp(source, { autoOrient: true }); applyCfImageTransforms(jsonTransformer, options); const { info } = await jsonTransformer.toBuffer({ resolveWithObject: true, @@ -418,7 +425,9 @@ export async function cfImageLocalFetcher(request: Request): Promise { }); } - const transformer = sharp(source); + // Production applies EXIF orientation before any transforms; + // autoOrient bakes the rotation into the pixels at decode. + const transformer = sharp(source, { autoOrient: true }); applyCfImageTransforms(transformer, options); const quality = resolveQuality(options.quality); diff --git a/packages/miniflare/test/plugins/images/transform.spec.ts b/packages/miniflare/test/plugins/images/transform.spec.ts index 8c96b7de19e..5a8e74f323f 100644 --- a/packages/miniflare/test/plugins/images/transform.spec.ts +++ b/packages/miniflare/test/plugins/images/transform.spec.ts @@ -70,7 +70,8 @@ describe("Images binding local transforms", () => { async function transform( transformOpts: Record, - format = "image/png" + format = "image/png", + source: Buffer = sourcePng ) { const params = new URLSearchParams({ transform: JSON.stringify(transformOpts), @@ -78,12 +79,24 @@ describe("Images binding local transforms", () => { }); const res = await mf.dispatchFetch(`http://localhost/?${params}`, { method: "POST", - body: sourcePng, + body: source, }); const body = Buffer.from(await res.arrayBuffer()); return { res, body }; } + // The 200x100 white-top/red-bottom source as a JPEG tagged with EXIF + // orientation 6 ("rotate 90° CW to display") - the layout phone cameras + // use for portrait photos. A production-matching transform bakes that + // rotation in, producing an upright 100x200 image with red on the left + // (the source's bottom half) and white on the right. + async function exifRotatedJpeg() { + return sharp(sourcePng) + .jpeg({ quality: 95 }) + .withMetadata({ orientation: 6 }) + .toBuffer(); + } + async function pixelAt(body: Buffer, x: number, y: number) { const { data, info } = await sharp(body) .raw() @@ -196,4 +209,40 @@ describe("Images binding local transforms", () => { const { r, g, b } = await pixelAt(body, 0, 0); expect([r, g, b]).toEqual([255, 255, 255]); }); + + test("EXIF orientation is applied before transforms (matches production)", async ({ + expect, + }) => { + // Production auto-orients per EXIF before transforming; without it the + // 200x100 source would pass through sideways as 200x100. + const { body } = await transform({}, "image/png", await exifRotatedJpeg()); + const meta = await sharp(body).metadata(); + expect(meta.width).toBe(100); + expect(meta.height).toBe(200); + + // After the 90° CW rotation the source's red bottom half lands on the + // left and the white top half on the right. JPEG encoding is lossy, so + // sample deep inside each half and allow small artifacts. + const left = await pixelAt(body, 25, 100); + expect(left.r).toBeGreaterThan(240); + expect(left.g).toBeLessThan(15); + expect(left.b).toBeLessThan(15); + const right = await pixelAt(body, 75, 100); + expect(right.r).toBeGreaterThan(240); + expect(right.g).toBeGreaterThan(240); + expect(right.b).toBeGreaterThan(240); + }); + + test("EXIF orientation composes with resize", async ({ expect }) => { + // width applies to the upright (100x200) image, not the stored + // sideways (200x100) pixels. + const { body } = await transform( + { width: 50 }, + "image/png", + await exifRotatedJpeg() + ); + const meta = await sharp(body).metadata(); + expect(meta.width).toBe(50); + expect(meta.height).toBe(100); + }); });