From d9c6b8107e16301ed551007b42f54abd2a75d8e7 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Mon, 27 Nov 2023 10:51:34 -0600 Subject: [PATCH] Make local resizing EXIF orientation aware --- .vscode/settings.json | 1 + package.json | 1 + pnpm-lock.yaml | 7 +++ src/components/ImageInput.tsx | 106 ++++++++++++++++++++++++++-------- 4 files changed, 92 insertions(+), 23 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index bd2aa265..5dd6e3ca 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,7 @@ "CredentialsSignin", "Eterna", "exif", + "exifr", "exiftool", "ghijklmnopqrstuv", "hgetall", diff --git a/package.json b/package.json index 20e38310..dc59653c 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "date-fns": "^2.30.0", "eslint": "8.54.0", "eslint-config-next": "14.0.3", + "exifr": "^7.1.3", "framer-motion": "^10.16.5", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1438535..1d53faf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ dependencies: eslint-config-next: specifier: 14.0.3 version: 14.0.3(eslint@8.54.0)(typescript@5.3.2) + exifr: + specifier: ^7.1.3 + version: 7.1.3 framer-motion: specifier: ^10.16.5 version: 10.16.5(react-dom@18.2.0)(react@18.2.0) @@ -3616,6 +3619,10 @@ packages: strip-final-newline: 2.0.0 dev: false + /exifr@7.1.3: + resolution: {integrity: sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==} + dev: false + /exit@0.1.2: resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} engines: {node: '>= 0.8.0'} diff --git a/src/components/ImageInput.tsx b/src/components/ImageInput.tsx index 37b3a1b1..c316fd34 100644 --- a/src/components/ImageInput.tsx +++ b/src/components/ImageInput.tsx @@ -3,6 +3,7 @@ import { blobToImage } from '@/utility/blob'; import { useRef, useState } from 'react'; import { CopyExif } from '@/lib/CopyExif'; +import exifr from 'exifr'; import { cc } from '@/utility/css'; import Spinner from './Spinner'; import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo'; @@ -91,41 +92,94 @@ export default function ImageInput({ hasMultipleUploads: files.length > 1, isLastBlob: i === files.length - 1, }; + const canvas = ref.current; - if (!(maxSize && canvas)) { - // No need to process - await onBlobReady?.({ - ...callbackArgs, - blob: file, - }); - } else { + + // Specify wide gamut to avoid data loss while resizing + const ctx = canvas?.getContext( + '2d', { colorSpace: 'display-p3' } + ); + + if (maxSize && canvas && ctx) { // Process images that need resizing const image = await blobToImage(file); + setImage(image); - const { naturalWidth, naturalHeight } = image; - const ratio = naturalWidth / naturalHeight; + + ctx.save(); + + let orientation = await exifr.orientation(file) ?? 1; + // Reverse engineer orientation + // so preserved EXIF data can be copied + switch (orientation) { + case 1: orientation = 1; break; + case 2: orientation = 1; break; + case 3: orientation = 3; break; + case 4: orientation = 1; break; + case 5: orientation = 1; break; + case 6: orientation = 8; break; + case 7: orientation = 1; break; + case 8: orientation = 6; break; + } + + const ratio = image.width / image.height; const width = Math.round(ratio >= 1 ? maxSize : maxSize * ratio); const height = Math.round(ratio >= 1 ? maxSize / ratio : maxSize); - + canvas.width = width; canvas.height = height; + + // Orientation transforms from: + // eslint-disable-next-line max-len + // https://gist.github.com/SagiMedina/f00a57de4e211456225d3114fd10b0d0 - // Specify wide gamut to avoid data loss while resizing - const ctx = canvas.getContext( - '2d', - { colorSpace: 'display-p3' }, - ); - - ctx?.drawImage( - image, - 0, - 0, - canvas.width, - canvas.height, - ); + switch(orientation) { + case 2: + ctx.translate(width, 0); + ctx.scale(-1, 1); + break; + case 3: + ctx.translate(width, height); + ctx.rotate((180 / 180) * Math.PI); + break; + case 4: + ctx.translate(0, height); + ctx.scale(1, -1); + break; + case 5: + canvas.width = height; + canvas.height = width; + ctx.rotate((90 / 180) * Math.PI); + ctx.scale(1, -1); + break; + case 6: + canvas.width = height; + canvas.height = width; + ctx.rotate((90 / 180) * Math.PI); + ctx.translate(0, -height); + break; + case 7: + canvas.width = height; + canvas.height = width; + ctx.rotate((270 / 180) * Math.PI); + ctx.translate(-width, height); + ctx.scale(1, -1); + break; + case 8: + canvas.width = height; + canvas.height = width; + ctx.translate(0, width); + ctx.rotate((270 / 180) * Math.PI); + break; + } + + ctx.drawImage(image, 0, 0, width, height); + + ctx.restore(); + canvas.toBlob( async blob => { if (blob) { @@ -139,6 +193,12 @@ export default function ImageInput({ 'image/jpeg', quality, ); + } else { + // No need to process + await onBlobReady?.({ + ...callbackArgs, + blob: file, + }); } } }