From 0ccdf491e664776f80dbcd86c57c0ae4bc936538 Mon Sep 17 00:00:00 2001 From: Manjusaka Date: Sat, 30 May 2026 19:30:20 +0800 Subject: [PATCH] fix(images): use EXIF-oriented dimensions in metadata refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The metadata-refresh task read image dimensions via sharp(buffer).metadata() without accounting for EXIF orientation. For orientation 5-8 (rotate 90/270/transpose/transverse — common on phone photos) sharp reports the stored, pre-rotation width/height, so the task persisted swapped dimensions to images.width/height. Because the gallery (and the new variant pipeline) treat those as displayed dimensions, rotated photos were laid out as landscape-when-portrait, and the bad values would also overwrite the correct browser-reported dimensions stored on upload. Extract the orientation-swap logic introduced in the variant generator into a shared getOrientedDimensions() helper and use it in both image-variants.ts (behavior-preserving refactor) and metadata-refresh.ts (the fix). Verified against sharp 0.34.5 across EXIF orientation 1-8: helper output matches the actual .rotate() pixel dimensions (1-4 unchanged, 5-8 swapped). Co-Authored-By: Claude Opus 4.8 --- server/lib/image-variants.ts | 45 +++++++++++++++++++++----------- server/tasks/metadata-refresh.ts | 14 +++++++--- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/server/lib/image-variants.ts b/server/lib/image-variants.ts index 06bc15c9..eb33e698 100644 --- a/server/lib/image-variants.ts +++ b/server/lib/image-variants.ts @@ -69,6 +69,32 @@ export function tierWidthsForSource(sourceWidth: number): number[] { return tiers.length > 0 ? tiers : [sourceWidth] } +/** + * Resolve an image's *displayed* dimensions from sharp metadata. + * + * sharp's `.metadata()` reports the stored (pre-rotation) width/height plus the + * EXIF `orientation` flag. For orientation 5-8 (transpose / rotate 90 / rotate + * 270 / transverse — very common on phone photos) the displayed image has its + * width and height swapped relative to storage. Anything that persists or lays + * out dimensions must use these oriented values so they match the + * EXIF-auto-oriented pixels (and the browser-reported dimensions stored for + * existing rows), avoiding portrait-as-landscape layout breakage. + * + * Returns `0` for any dimension sharp could not read. + */ +export function getOrientedDimensions( + metadata: { width?: number; height?: number; orientation?: number }, +): { width: number; height: number } { + const storedWidth = metadata.width ?? 0 + const storedHeight = metadata.height ?? 0 + const orientation = metadata.orientation ?? 1 + const swap = orientation >= 5 && orientation <= 8 + return { + width: swap ? storedHeight : storedWidth, + height: swap ? storedWidth : storedHeight, + } +} + export interface GeneratedVariant { width: number format: VariantFormat @@ -108,24 +134,13 @@ export async function generateImageVariants( const metadata = await sharp(input, { limitInputPixels: MAX_INPUT_PIXELS, failOn: 'none' }).metadata() - const storedWidth = metadata.width ?? 0 - const storedHeight = metadata.height ?? 0 - if (storedWidth <= 0 || storedHeight <= 0) { + // The variants below are produced via `.rotate()` (EXIF auto-orientation), so + // report the oriented dimensions (see getOrientedDimensions). + const { width: sourceWidth, height: sourceHeight } = getOrientedDimensions(metadata) + if (sourceWidth <= 0 || sourceHeight <= 0) { throw new Error('Unable to read source image dimensions') } - // sharp's `.metadata()` reports the stored (pre-rotation) dimensions, while - // the variants below are produced via `.rotate()` (EXIF auto-orientation). - // For orientation 5-8 (transpose / rotate 90 / rotate 270 / transverse — - // very common on phone photos) the displayed image has width and height - // swapped. Report the oriented dimensions so they match both the generated - // variant pixels and the browser-reported dimensions stored for existing - // rows (avoiding portrait-as-landscape gallery layout breakage). - const orientation = metadata.orientation ?? 1 - const swapDimensions = orientation >= 5 && orientation <= 8 - const sourceWidth = swapDimensions ? storedHeight : storedWidth - const sourceHeight = swapDimensions ? storedWidth : storedHeight - const blurhash = await generateThumbhash(input) const variants: GeneratedVariant[] = [] diff --git a/server/tasks/metadata-refresh.ts b/server/tasks/metadata-refresh.ts index 29889bb5..55a55241 100644 --- a/server/tasks/metadata-refresh.ts +++ b/server/tasks/metadata-refresh.ts @@ -8,6 +8,7 @@ import { DOMParser } from '@xmldom/xmldom' dayjs.extend(customParseFormat) +import { getOrientedDimensions } from '~/server/lib/image-variants' import type { ExifType } from '~/types' import type { AdminTaskIssue, AdminTaskStage } from '~/types/admin-tasks' import { ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA } from '~/types/admin-tasks' @@ -448,11 +449,16 @@ export async function refreshImageMetadata(image: MetadataRefreshImage, signal?: const metadata = await sharp(buffer).metadata() throwIfMetadataTaskCancelled(signal) - if (metadata.width && metadata.width > 0) { - widthCandidate = metadata.width + // Use the EXIF-oriented dimensions so rotated images (orientation 5-8) get + // their displayed width/height persisted, matching what the browser stored + // on upload and what the variant pipeline generates. Reading the raw + // metadata here would write swapped dimensions for portrait phone photos. + const oriented = getOrientedDimensions(metadata) + if (oriented.width > 0) { + widthCandidate = oriented.width } - if (metadata.height && metadata.height > 0) { - heightCandidate = metadata.height + if (oriented.height > 0) { + heightCandidate = oriented.height } } catch (error) { if (signal?.aborted || isMetadataTaskCancelledError(error)) {