From 77926383dbfa6e7dc9ffe08b2975b18bce55ee92 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Mon, 15 Dec 2025 18:36:46 +0100 Subject: [PATCH] fix(server): only extract image's duration if format supports animation (#24587) --- server/src/services/metadata.service.spec.ts | 16 +++++++++++++++- server/src/services/metadata.service.ts | 4 +++- server/src/utils/mime-types.spec.ts | 20 ++++++++++++++++++++ server/src/utils/mime-types.ts | 6 ++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 46d6fe7abc..c0f930d3a6 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1034,7 +1034,10 @@ describe(MetadataService.name, () => { }); it('should use Duration from exif', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ + ...assetStub.image, + originalPath: '/original/path.webp', + }); mockReadTags({ Duration: 123 }, {}); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -1046,6 +1049,7 @@ describe(MetadataService.name, () => { it('should prefer Duration from exif over sidecar', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ ...assetStub.image, + originalPath: '/original/path.webp', files: [ { id: 'some-id', @@ -1063,6 +1067,16 @@ describe(MetadataService.name, () => { expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' })); }); + it('should ignore all Duration tags for definitely static images', async () => { + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.imageDng); + mockReadTags({ Duration: 123 }, { Duration: 456 }); + + await sut.handleMetadataExtraction({ id: assetStub.imageDng.id }); + + expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1); + expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: null })); + }); + it('should ignore Duration from exif for videos', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video); mockReadTags({ Duration: 123 }, {}); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 4d6d4c190f..a1267604b2 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -32,6 +32,7 @@ import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; import { isAssetChecksumConstraint } from 'src/utils/database'; +import { mimeTypes } from 'src/utils/mime-types'; import { isFaceImportEnabled } from 'src/utils/misc'; import { upsertTags } from 'src/utils/tag'; @@ -486,7 +487,8 @@ export class MetadataService extends BaseService { } // prefer duration from video tags - if (videoTags) { + // don't save duration if asset is definitely not an animated image (see e.g. CR3 with Duration: 1s) + if (videoTags || !mimeTypes.isPossiblyAnimatedImage(asset.originalPath)) { delete mediaTags.Duration; } diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts index 12587eff37..c09f3a381b 100644 --- a/server/src/utils/mime-types.spec.ts +++ b/server/src/utils/mime-types.spec.ts @@ -152,6 +152,26 @@ describe('mimeTypes', () => { } }); + describe('animated image', () => { + for (const img of ['a.avif', 'a.gif', 'a.webp']) { + it('should identify animated image mime types as such', () => { + expect(mimeTypes.isPossiblyAnimatedImage(img)).toBeTruthy(); + }); + } + + for (const img of ['a.cr3', 'a.jpg', 'a.tiff']) { + it('should identify static image mime types as such', () => { + expect(mimeTypes.isPossiblyAnimatedImage(img)).toBeFalsy(); + }); + } + + for (const extension of Object.keys(mimeTypes.video)) { + it('should not identify video mime types as animated', () => { + expect(mimeTypes.isPossiblyAnimatedImage(extension)).toBeFalsy(); + }); + } + }); + describe('video', () => { it('should contain only lowercase mime types', () => { const keys = Object.keys(mimeTypes.video); diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index 6b9392146d..d15c1f078c 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -64,6 +64,11 @@ const image: Record = { '.tiff': ['image/tiff'], }; +const possiblyAnimatedImageExtensions = new Set(['.avif', '.gif', '.heic', '.heif', '.jxl', '.png', '.webp']); +const possiblyAnimatedImage: Record = Object.fromEntries( + Object.entries(image).filter(([key]) => possiblyAnimatedImageExtensions.has(key)), +); + const extensionOverrides: Record = { 'image/jpeg': '.jpg', }; @@ -119,6 +124,7 @@ export const mimeTypes = { isAsset: (filename: string) => isType(filename, image) || isType(filename, video), isImage: (filename: string) => isType(filename, image), isWebSupportedImage: (filename: string) => isType(filename, webSupportedImage), + isPossiblyAnimatedImage: (filename: string) => isType(filename, possiblyAnimatedImage), isProfile: (filename: string) => isType(filename, profile), isSidecar: (filename: string) => isType(filename, sidecar), isVideo: (filename: string) => isType(filename, video),