From 8c096e3572f83bad8185eee79a4b14a6e4693e6c Mon Sep 17 00:00:00 2001 From: holegots Date: Mon, 22 Jun 2026 01:38:37 +0800 Subject: [PATCH 1/5] fix(server): correct image dimensions from exif metadata --- .../src/repositories/metadata.repository.ts | 16 +++++++- server/src/services/metadata.service.spec.ts | 38 +++++++++++++++++++ server/src/services/metadata.service.ts | 15 +++++--- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 94047bf326..2fe09eb59d 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -88,8 +88,20 @@ export class MetadataRepository { /* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */ geoTz: (lat, lon) => geotz.find(lat, lon)[0], geolocation: true, - // Enable exiftool LFS to parse metadata for files larger than 2GB. - readArgs: ['-api', 'largefilesupport=1', '--ICC_Profile:DeviceManufacturer', '--ICC_Profile:DeviceModelName'], + readArgs: [ + // Enable exiftool LFS to parse metadata for files larger than 2GB. + '-api', + 'largefilesupport=1', + '--ICC_Profile:DeviceManufacturer', + '--ICC_Profile:DeviceModelName', + // Ignore embedded thumbnail dimensions/orientation for the main asset. + '-x', + 'IFD1:Orientation', + '-x', + 'IFD1:ImageWidth', + '-x', + 'IFD1:ImageHeight', + ], writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'], taskTimeoutMillis: 2 * 60 * 1000, }); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 57c029961e..dde5060be8 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1836,6 +1836,44 @@ describe(MetadataService.name, () => { ); }); + it('should prefer ExifImageWidth/Height over ImageSize', async () => { + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); + mockReadTags({ + ExifImageWidth: 8448, + ExifImageHeight: 6336, + ImageSize: '9600x6376', + ImageWidth: 9600, + ImageHeight: 6376, + }); + + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.asset.update).toHaveBeenCalledWith( + expect.objectContaining({ + width: 8448, + height: 6336, + }), + ); + }); + + it('should fall back to ImageSize when ExifImageWidth/Height is missing', async () => { + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); + mockReadTags({ + ImageSize: '6000x4000', + ImageWidth: 1620, + ImageHeight: 1080, + }); + + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.asset.update).toHaveBeenCalledWith( + expect.objectContaining({ + width: 6000, + height: 4000, + }), + ); + }); + 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..f331e5b383 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -562,13 +562,16 @@ export class MetadataService extends BaseService { private getImageDimensions(exifTags: ImmichTags): { width?: number; height?: number } { /* * The "true" values for width and height are a bit hidden, depending on the camera model and file format. - * For RAW images in the CR2 or RAF format, the "ImageSize" value seems to be correct, - * but ImageWidth and ImageHeight are not correct (they contain the dimensions of the preview image). + * ExifImageWidth/Height is the rendered image size for cropped RAWs, while ImageSize is still needed for + * formats where ImageWidth/ImageHeight point at the embedded preview. */ - let [width, height] = - exifTags.ImageSize?.toString() - ?.split('x') - ?.map((dim) => Number.parseInt(dim) || undefined) ?? []; + let [width, height] = [exifTags.ExifImageWidth, exifTags.ExifImageHeight]; + if (!width || !height) { + [width, height] = + exifTags.ImageSize?.toString() + ?.split('x') + ?.map((dim) => Number.parseInt(dim) || undefined) ?? []; + } if (!width || !height) { [width, height] = [exifTags.ImageWidth, exifTags.ImageHeight]; } From 923303afd5f46d491fe0cb6b5a78f92e5e6b40e3 Mon Sep 17 00:00:00 2001 From: holegots Date: Fri, 26 Jun 2026 17:42:39 +0800 Subject: [PATCH 2/5] fix(server): cover ifd1 metadata handling --- .../src/repositories/metadata.repository.ts | 10 ++-- server/src/services/metadata.service.spec.ts | 38 ------------- .../specs/services/metadata.service.spec.ts | 57 +++++++++++++++++++ 3 files changed, 61 insertions(+), 44 deletions(-) diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 2fe09eb59d..0f06f76578 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -95,12 +95,10 @@ export class MetadataRepository { '--ICC_Profile:DeviceManufacturer', '--ICC_Profile:DeviceModelName', // Ignore embedded thumbnail dimensions/orientation for the main asset. - '-x', - 'IFD1:Orientation', - '-x', - 'IFD1:ImageWidth', - '-x', - 'IFD1:ImageHeight', + '--IFD1:Orientation', + '--MWG:Orientation', + '--IFD1:ImageWidth', + '--IFD1:ImageHeight', ], writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'], taskTimeoutMillis: 2 * 60 * 1000, diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index dde5060be8..57c029961e 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1836,44 +1836,6 @@ describe(MetadataService.name, () => { ); }); - it('should prefer ExifImageWidth/Height over ImageSize', async () => { - const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); - mockReadTags({ - ExifImageWidth: 8448, - ExifImageHeight: 6336, - ImageSize: '9600x6376', - ImageWidth: 9600, - ImageHeight: 6376, - }); - - await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.asset.update).toHaveBeenCalledWith( - expect.objectContaining({ - width: 8448, - height: 6336, - }), - ); - }); - - it('should fall back to ImageSize when ExifImageWidth/Height is missing', async () => { - const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); - mockReadTags({ - ImageSize: '6000x4000', - ImageWidth: 1620, - ImageHeight: 1080, - }); - - await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.asset.update).toHaveBeenCalledWith( - expect.objectContaining({ - width: 6000, - height: 4000, - }), - ); - }); - 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/test/medium/specs/services/metadata.service.spec.ts b/server/test/medium/specs/services/metadata.service.spec.ts index 6dc66e3ed5..59bdbbf416 100644 --- a/server/test/medium/specs/services/metadata.service.spec.ts +++ b/server/test/medium/specs/services/metadata.service.spec.ts @@ -1,3 +1,4 @@ +import { exiftool } from 'exiftool-vendored'; import { Kysely } from 'kysely'; import { Stats } from 'node:fs'; import { writeFile } from 'node:fs/promises'; @@ -151,5 +152,61 @@ describe(MetadataService.name, () => { // note that this date is technically wrong. it does not throw though and should get the user's attention either way. ).resolves.toEqual({ dateTimeOriginal: new Date('4260-03-05T04:04:12.000Z') }); }); + + it('should ignore IFD1 thumbnail orientation when extracting metadata', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + const { filePath } = await createTestFile({ Description: '' }); + await exiftool.write(filePath, {}, ['-overwrite_original', '-IFD1:Orientation#=6']); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ originalPath: filePath, ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: '' }); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect( + ctx.database + .selectFrom('asset_exif') + .select('orientation') + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ orientation: null }); + }); + + it('should ignore IFD1 thumbnail dimensions when extracting metadata', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + const { filePath } = await createTestFile({ Description: '' }); + await exiftool.write(filePath, {}, ['-overwrite_original', '-IFD1:ImageWidth#=160', '-IFD1:ImageHeight#=120']); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ originalPath: filePath, ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: '' }); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toEqual( + expect.objectContaining({ width: 1, height: 1 }), + ); + }); + + it('should keep IFD0 orientation when extracting metadata', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + const { filePath } = await createTestFile({ Description: '' }); + await exiftool.write(filePath, {}, ['-overwrite_original', '-IFD0:Orientation#=6']); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ originalPath: filePath, ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: '' }); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect( + ctx.database + .selectFrom('asset_exif') + .select('orientation') + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ orientation: '6' }); + }); }); }); From 650a2e8d467d9dcb9731346c17cfe36afdc65b9e Mon Sep 17 00:00:00 2001 From: Daniel Dietzler Date: Fri, 26 Jun 2026 17:43:32 +0200 Subject: [PATCH 3/5] chore: cleanup --- .../medium/specs/services/metadata.service.spec.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/server/test/medium/specs/services/metadata.service.spec.ts b/server/test/medium/specs/services/metadata.service.spec.ts index 59bdbbf416..0c076b8f3c 100644 --- a/server/test/medium/specs/services/metadata.service.spec.ts +++ b/server/test/medium/specs/services/metadata.service.spec.ts @@ -1,4 +1,3 @@ -import { exiftool } from 'exiftool-vendored'; import { Kysely } from 'kysely'; import { Stats } from 'node:fs'; import { writeFile } from 'node:fs/promises'; @@ -156,8 +155,7 @@ describe(MetadataService.name, () => { it('should ignore IFD1 thumbnail orientation when extracting metadata', async () => { const { sut, ctx } = setup(); ctx.getMock(EventRepository).emit.mockResolvedValue(); - const { filePath } = await createTestFile({ Description: '' }); - await exiftool.write(filePath, {}, ['-overwrite_original', '-IFD1:Orientation#=6']); + const { filePath } = await createTestFile({ 'IFD1:Orientation#': 6 }); const { user } = await ctx.newUser(); const { asset } = await ctx.newAsset({ originalPath: filePath, ownerId: user.id }); await ctx.newExif({ assetId: asset.id, description: '' }); @@ -176,8 +174,7 @@ describe(MetadataService.name, () => { it('should ignore IFD1 thumbnail dimensions when extracting metadata', async () => { const { sut, ctx } = setup(); ctx.getMock(EventRepository).emit.mockResolvedValue(); - const { filePath } = await createTestFile({ Description: '' }); - await exiftool.write(filePath, {}, ['-overwrite_original', '-IFD1:ImageWidth#=160', '-IFD1:ImageHeight#=120']); + const { filePath } = await createTestFile({ 'IFD1:ImageWidth#': 160, 'IFD1:ImageHeight#': 120 }); const { user } = await ctx.newUser(); const { asset } = await ctx.newAsset({ originalPath: filePath, ownerId: user.id }); await ctx.newExif({ assetId: asset.id, description: '' }); @@ -192,8 +189,7 @@ describe(MetadataService.name, () => { it('should keep IFD0 orientation when extracting metadata', async () => { const { sut, ctx } = setup(); ctx.getMock(EventRepository).emit.mockResolvedValue(); - const { filePath } = await createTestFile({ Description: '' }); - await exiftool.write(filePath, {}, ['-overwrite_original', '-IFD0:Orientation#=6']); + const { filePath } = await createTestFile({ 'IFD0:Orientation#': 6 }); const { user } = await ctx.newUser(); const { asset } = await ctx.newAsset({ originalPath: filePath, ownerId: user.id }); await ctx.newExif({ assetId: asset.id, description: '' }); From 31472a712bf71370678df41d3a76d59c1ed7f5fc Mon Sep 17 00:00:00 2001 From: holegots Date: Sat, 27 Jun 2026 13:19:01 +0800 Subject: [PATCH 4/5] test(e2e): update raw asset dimension expectations --- e2e/src/specs/server/api/asset.e2e-spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/e2e/src/specs/server/api/asset.e2e-spec.ts b/e2e/src/specs/server/api/asset.e2e-spec.ts index ccebd1ca77..4d0f9ff055 100644 --- a/e2e/src/specs/server/api/asset.e2e-spec.ts +++ b/e2e/src/specs/server/api/asset.e2e-spec.ts @@ -875,8 +875,8 @@ describe('/asset', () => { exifInfo: { make: 'SONY', model: 'ILCE-6300', - exifImageHeight: 4024, - exifImageWidth: 6048, + exifImageHeight: 4000, + exifImageWidth: 6000, exposureTime: '1/320', fNumber: 8, focalLength: 97, @@ -897,8 +897,8 @@ describe('/asset', () => { exifInfo: { make: 'SONY', model: 'ILCE-7M2', - exifImageHeight: 4024, - exifImageWidth: 6048, + exifImageHeight: 4000, + exifImageWidth: 6000, exposureTime: '1.3', fNumber: 22, focalLength: 25, @@ -943,8 +943,8 @@ describe('/asset', () => { exifInfo: { make: 'FUJIFILM', model: 'X100V', - exifImageHeight: 4160, - exifImageWidth: 6240, + exifImageHeight: 2944, + exifImageWidth: 4416, exposureTime: '1/4000', fNumber: 16, focalLength: 23, From e3ea84a0d530efe771a3d87051b6388c6aa269c9 Mon Sep 17 00:00:00 2001 From: holegots Date: Wed, 1 Jul 2026 02:17:25 +0800 Subject: [PATCH 5/5] fix(server): keep image size priority for metadata --- e2e/src/specs/server/api/asset.e2e-spec.ts | 12 ++++++------ server/src/services/metadata.service.ts | 15 ++++++--------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/e2e/src/specs/server/api/asset.e2e-spec.ts b/e2e/src/specs/server/api/asset.e2e-spec.ts index 4d0f9ff055..ccebd1ca77 100644 --- a/e2e/src/specs/server/api/asset.e2e-spec.ts +++ b/e2e/src/specs/server/api/asset.e2e-spec.ts @@ -875,8 +875,8 @@ describe('/asset', () => { exifInfo: { make: 'SONY', model: 'ILCE-6300', - exifImageHeight: 4000, - exifImageWidth: 6000, + exifImageHeight: 4024, + exifImageWidth: 6048, exposureTime: '1/320', fNumber: 8, focalLength: 97, @@ -897,8 +897,8 @@ describe('/asset', () => { exifInfo: { make: 'SONY', model: 'ILCE-7M2', - exifImageHeight: 4000, - exifImageWidth: 6000, + exifImageHeight: 4024, + exifImageWidth: 6048, exposureTime: '1.3', fNumber: 22, focalLength: 25, @@ -943,8 +943,8 @@ describe('/asset', () => { exifInfo: { make: 'FUJIFILM', model: 'X100V', - exifImageHeight: 2944, - exifImageWidth: 4416, + exifImageHeight: 4160, + exifImageWidth: 6240, exposureTime: '1/4000', fNumber: 16, focalLength: 23, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index f331e5b383..171dcfe514 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -562,16 +562,13 @@ export class MetadataService extends BaseService { private getImageDimensions(exifTags: ImmichTags): { width?: number; height?: number } { /* * The "true" values for width and height are a bit hidden, depending on the camera model and file format. - * ExifImageWidth/Height is the rendered image size for cropped RAWs, while ImageSize is still needed for - * formats where ImageWidth/ImageHeight point at the embedded preview. + * For RAW images in the CR2 or RAF format, the "ImageSize" value seems to be correct, + * but ImageWidth and ImageHeight are not correct (they contain the dimensions of the preview image). */ - let [width, height] = [exifTags.ExifImageWidth, exifTags.ExifImageHeight]; - if (!width || !height) { - [width, height] = - exifTags.ImageSize?.toString() - ?.split('x') - ?.map((dim) => Number.parseInt(dim) || undefined) ?? []; - } + let [width, height] = + exifTags.ImageSize?.toString() + ?.split('x') + ?.map((dim) => Number.parseInt(dim) || undefined) ?? []; if (!width || !height) { [width, height] = [exifTags.ImageWidth, exifTags.ImageHeight]; }