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
This commit is contained in:
mrcksz 2026-07-30 05:04:06 +00:00
parent 0293414abd
commit 0da258a418
2 changed files with 40 additions and 1 deletions

View file

@ -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));

View file

@ -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;