From 0da258a4181997ff8825b5483aa3d28b1f59bcc0 Mon Sep 17 00:00:00 2001 From: mrcksz <4648381+mrcksz@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:04:06 +0000 Subject: [PATCH] fix(server): don't trust file extension for HEIF orientation handling Files can carry a .heic/.heif/.avif extension without actually being encoded as such - e.g. when a tool converts HEIC to JPEG in place but keeps the original filename/extension. Since the HEIF-vs-classic-EXIF orientation branch in getExifTags() was keyed purely off mimeTypes.isHeifImage(asset.originalPath) (extension only), such files had their perfectly valid EXIF Orientation tag silently discarded (because no QuickTime Rotation/irot atom exists in a real JPEG), which in turn left width/height unswapped for sideways images and produced a squished/stretched thumbnail. Now the branch is only taken when ExifTool's own content-detected FileTypeExtension also identifies the file as HEIF-family, reusing the existing mimeTypes.isHeifImage() extension set instead of hardcoding a new one. Fixes #29766 --- server/src/services/metadata.service.spec.ts | 30 ++++++++++++++++++++ server/src/services/metadata.service.ts | 11 ++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 57c029961e..8f7fe71be9 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1836,6 +1836,36 @@ describe(MetadataService.name, () => { ); }); + it('should still swap width/height using EXIF Orientation for a .heic-named file that is actually JPEG', async () => { + const asset = AssetFactory.create({ originalFileName: 'IMG_1.heic' }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); + // no QuickTime `Rotation` tag and FileTypeExtension `jpg` - this is what ExifTool reports + // for a file that was converted to JPEG in place but kept its .heic extension/filename. + mockReadTags({ ImageWidth: 1000, ImageHeight: 2000, Orientation: 6, FileTypeExtension: 'jpg' }); + + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.asset.update).toHaveBeenCalledWith( + expect.objectContaining({ + width: 2000, + height: 1000, + }), + ); + }); + + it('should ignore EXIF Orientation and use QuickTime Rotation for a genuine HEIC file', async () => { + const asset = AssetFactory.create({ originalFileName: 'IMG_1.heic' }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); + mockReadTags({ ImageWidth: 1000, ImageHeight: 2000, Orientation: 6, FileTypeExtension: 'heic', Rotation: 0 }); + + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.asset.update).toHaveBeenCalledWith( + expect.objectContaining({ + width: 1000, + height: 2000, + }), + ); + }); + it('should overwrite existing width/height for unedited assets', async () => { const asset = AssetFactory.create({ width: 1920, height: 1080, isEdited: false }); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 171dcfe514..65f4ea84aa 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -616,7 +616,16 @@ export class MetadataService extends BaseService { // don't use Exif Orientation for HEIF based images, it's usually missing or invalid. // prefer irot (ExifTool QuickTime:Rotation) mapped to ExifOrientation. - if (mimeTypes.isHeifImage(asset.originalPath)) { + // + // Some files carry a .heic/.heif/.avif extension without actually being encoded + // as such (e.g. converted to JPEG in place by external tools before being + // imported). Trusting the filename alone would discard a perfectly valid EXIF + // Orientation tag below and lead to a squished/stretched thumbnail, since the + // dimensions never get swapped for the sideways rotation. ExifTool's own + // content-detected FileTypeExtension is the source of truth here, not the name. + const isActuallyHeif = + mimeTypes.isHeifImage(asset.originalPath) && mimeTypes.isHeifImage(`file.${mediaTags.FileTypeExtension ?? ''}`); + if (isActuallyHeif) { const orientation = this.getHeifOrientation(mediaTags); if (orientation === null) { delete mediaTags.Orientation;