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/app/(static)/film/[simulation]/image/route.tsx b/src/app/(static)/film/[simulation]/image/route.tsx index fafe45ad..7b41da1d 100644 --- a/src/app/(static)/film/[simulation]/image/route.tsx +++ b/src/app/(static)/film/[simulation]/image/route.tsx @@ -1,7 +1,7 @@ import { auth } from '@/auth'; import { getImageCacheHeadersForAuth, getPhotosCached } from '@/cache'; import { - IMAGE_OG_SMALL_SIZE, + IMAGE_OG_DIMENSION_SMALL, MAX_PHOTOS_TO_SHOW_PER_TAG, } from '@/photo/image-response'; import FilmSimulationImageResponse from @@ -28,7 +28,7 @@ export async function GET( getImageCacheHeadersForAuth(await auth()), ]); - const { width, height } = IMAGE_OG_SMALL_SIZE; + const { width, height } = IMAGE_OG_DIMENSION_SMALL; return new ImageResponse( , diff --git a/src/app/(static)/p/[photoId]/image/route.tsx b/src/app/(static)/p/[photoId]/image/route.tsx index ae887b7e..da60e6ab 100644 --- a/src/app/(static)/p/[photoId]/image/route.tsx +++ b/src/app/(static)/p/[photoId]/image/route.tsx @@ -1,6 +1,6 @@ import { auth } from '@/auth'; import { getImageCacheHeadersForAuth, getPhotoCached } from '@/cache'; -import { IMAGE_OG_SIZE } from '@/photo/image-response'; +import { IMAGE_OG_DIMENSION } from '@/photo/image-response'; import PhotoImageResponse from '@/photo/image-response/PhotoImageResponse'; import { getIBMPlexMonoMedium } from '@/site/font'; import { ImageResponse } from 'next/og'; @@ -23,7 +23,7 @@ export async function GET( if (!photo) { return new Response('Photo not found', { status: 404 }); } - const { width, height } = IMAGE_OG_SIZE; + const { width, height } = IMAGE_OG_DIMENSION; return new ImageResponse( , diff --git a/src/app/(static)/shot-on/[camera]/image/route.tsx b/src/app/(static)/shot-on/[camera]/image/route.tsx index a453f2a4..21473e2d 100644 --- a/src/app/(static)/shot-on/[camera]/image/route.tsx +++ b/src/app/(static)/shot-on/[camera]/image/route.tsx @@ -2,7 +2,7 @@ import { auth } from '@/auth'; import { getImageCacheHeadersForAuth, getPhotosCached } from '@/cache'; import { getCameraFromKey } from '@/camera'; import { - IMAGE_OG_SMALL_SIZE, + IMAGE_OG_DIMENSION_SMALL, MAX_PHOTOS_TO_SHOW_PER_TAG, } from '@/photo/image-response'; import CameraImageResponse from '@/photo/image-response/CameraImageResponse'; @@ -30,7 +30,7 @@ export async function GET( getImageCacheHeadersForAuth(await auth()), ]); - const { width, height } = IMAGE_OG_SMALL_SIZE; + const { width, height } = IMAGE_OG_DIMENSION_SMALL; return new ImageResponse( 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, + }); } } } diff --git a/src/components/OGTile.tsx b/src/components/OGTile.tsx index da11f0a7..fd1cd0bd 100644 --- a/src/components/OGTile.tsx +++ b/src/components/OGTile.tsx @@ -5,7 +5,7 @@ import { cc } from '@/utility/css'; import Link from 'next/link'; import { BiError } from 'react-icons/bi'; import Spinner from '@/components/Spinner'; -import { IMAGE_OG_SIZE } from '../photo/image-response'; +import { IMAGE_OG_DIMENSION } from '../photo/image-response'; export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed'; @@ -44,7 +44,7 @@ export default function OGTile({ } }, [loadingStateExternal, loadingStateInternal]); - const { width, height, ratio } = IMAGE_OG_SIZE; + const { width, height, aspectRatio } = IMAGE_OG_DIMENSION; return ( {loadingState === 'loading' && { @@ -99,29 +104,36 @@ export default function PhotoForm({ return ( - - - {debugBlur && formData.blurData && - } + + + + {debugBlur && formData.blurData && + } + => ({ - aspectRatio: ( - (data.imageSize?.width ?? 3.0) / - (data.imageSize?.height ?? 2.0) - ).toString(), + aspectRatio: getAspectRatioFromExif(data).toString(), make: data.tags?.Make, model: data.tags?.Model, focalLength: data.tags?.FocalLength?.toString(), diff --git a/src/photo/image-response/index.ts b/src/photo/image-response/index.ts index 4721e332..37fd8a6a 100644 --- a/src/photo/image-response/index.ts +++ b/src/photo/image-response/index.ts @@ -1,35 +1,36 @@ import { NextImageSize } from '@/services/next-image'; +import { getDimensionsFromSize } from '@/utility/size'; export const MAX_PHOTOS_TO_SHOW_OG = 12; export const MAX_PHOTOS_TO_SHOW_PER_TAG = 6; export const MAX_PHOTOS_TO_SHOW_TEMPLATE = 16; export const MAX_PHOTOS_TO_SHOW_TEMPLATE_TIGHT = 12; +interface OGImageDimension { + width: NextImageSize + height: number + aspectRatio: number +} + // 16:9 og image ratio const IMAGE_OG_RATIO = 16 / 9; const IMAGE_OG_WIDTH: NextImageSize = 1080; -const IMAGE_OG_HEIGHT = IMAGE_OG_WIDTH * (1 / IMAGE_OG_RATIO); -export const IMAGE_OG_SIZE = { - width: IMAGE_OG_WIDTH, - height: IMAGE_OG_HEIGHT, - ratio: IMAGE_OG_RATIO, -}; +export const IMAGE_OG_DIMENSION = getDimensionsFromSize( + IMAGE_OG_WIDTH, + IMAGE_OG_RATIO, +) as OGImageDimension; // 16:9 og image ratio, small const IMAGE_OG_SMALL_WIDTH: NextImageSize = 828; -const IMAGE_OG_SMALL_HEIGHT = IMAGE_OG_SMALL_WIDTH * (1 / IMAGE_OG_RATIO); -export const IMAGE_OG_SMALL_SIZE = { - width: IMAGE_OG_SMALL_WIDTH, - height: IMAGE_OG_SMALL_HEIGHT, - ratio: IMAGE_OG_RATIO, -}; +export const IMAGE_OG_DIMENSION_SMALL = getDimensionsFromSize( + IMAGE_OG_SMALL_WIDTH, + IMAGE_OG_RATIO, +) as OGImageDimension; -// 3:2 og grid ratio -const GRID_OG_RATIO = 1.33; +// 4:3 og grid ratio +const GRID_OG_RATIO = 4 / 3; const GRID_OG_WIDTH: NextImageSize = 2048; -const GRID_OG_HEIGHT = GRID_OG_WIDTH * (1 / GRID_OG_RATIO); -export const GRID_OG_SIZE = { - width: GRID_OG_WIDTH, - height: GRID_OG_HEIGHT, - ratio: GRID_OG_RATIO, -}; +export const GRID_OG_DIMENSION = getDimensionsFromSize( + GRID_OG_WIDTH, + GRID_OG_RATIO, +) as OGImageDimension; diff --git a/src/utility/exif.ts b/src/utility/exif.ts index 684ee13f..c83b8ac6 100644 --- a/src/utility/exif.ts +++ b/src/utility/exif.ts @@ -1,4 +1,4 @@ -import type { ExifData } from 'ts-exif-parser'; +import { OrientationTypes, type ExifData } from 'ts-exif-parser'; import { formatNumberToFraction } from './number'; const OFFSET_REGEX = /[+-]\d\d:\d\d/; @@ -10,6 +10,27 @@ export const getOffsetFromExif = (data: ExifData) => OFFSET_REGEX.test(value) ) as string | undefined; +export const getAspectRatioFromExif = (data: ExifData): number => { + // Using '||' operator to handle `Orientation` unexpectedly being '0' + const orientation = data.tags?.Orientation || OrientationTypes.TOP_LEFT; + + const width = data.imageSize?.width ?? 3.0; + const height = data.imageSize?.height ?? 2.0; + + switch (orientation) { + case OrientationTypes.TOP_LEFT: + case OrientationTypes.TOP_RIGHT: + case OrientationTypes.BOTTOM_RIGHT: + case OrientationTypes.BOTTOM_LEFT: + case OrientationTypes.LEFT_TOP: + case OrientationTypes.RIGHT_BOTTOM: + return width / height; + case OrientationTypes.RIGHT_TOP: + case OrientationTypes.LEFT_BOTTOM: + return height / width; + } +}; + export const formatFocalLength = (focalLength?: number) => focalLength ? `${focalLength}mm` : undefined; diff --git a/src/utility/size.ts b/src/utility/size.ts new file mode 100644 index 00000000..53fa0c94 --- /dev/null +++ b/src/utility/size.ts @@ -0,0 +1,29 @@ +const DEFAULT_ASPECT_RATIO = 3.0 / 2.0; + +export const getDimensionsFromSize = ( + size: number, + aspectRatioRaw?: string | number, +): { + width: number + height: number + aspectRatio: number +} => { + const aspectRatio = typeof aspectRatioRaw === 'string' + ? parseFloat(aspectRatioRaw) + : aspectRatioRaw || DEFAULT_ASPECT_RATIO; + + let width = size; + let height = size; + + if (aspectRatio > 1) { + height = size / aspectRatio; + } else if (aspectRatio < 1) { + width = size * aspectRatio; + } + + return { + width: Math.round(width), + height: Math.round(height), + aspectRatio, + }; +};