From 76c73549ae8ae87003b7d167daad8ae049447992 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Wed, 19 Nov 2025 04:02:52 +0100 Subject: [PATCH] feat(web): always view original of animated images (#23842) --- .../asset-viewer/photo-viewer.spec.ts | 107 ++++++++++++++++-- .../asset-viewer/photo-viewer.svelte | 7 +- .../assets/thumbnail/thumbnail.svelte | 4 +- 3 files changed, 104 insertions(+), 14 deletions(-) diff --git a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts index 9e9f8fae62..fd1a40e4db 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts +++ b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts @@ -1,7 +1,7 @@ import { getAnimateMock } from '$lib/__mocks__/animate.mock'; import PhotoViewer from '$lib/components/asset-viewer/photo-viewer.svelte'; import * as utils from '$lib/utils'; -import { AssetMediaSize } from '@immich/sdk'; +import { AssetMediaSize, AssetTypeEnum } from '@immich/sdk'; import { assetFactory } from '@test-data/factories/asset-factory'; import { sharedLinkFactory } from '@test-data/factories/shared-link-factory'; import { render } from '@testing-library/svelte'; @@ -65,7 +65,11 @@ describe('PhotoViewer component', () => { }); it('loads the thumbnail', () => { - const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' }); + const asset = assetFactory.build({ + originalPath: 'image.jpg', + originalMimeType: 'image/jpeg', + type: AssetTypeEnum.Image, + }); render(PhotoViewer, { asset }); expect(getAssetThumbnailUrlSpy).toBeCalledWith({ @@ -76,16 +80,89 @@ describe('PhotoViewer component', () => { expect(getAssetOriginalUrlSpy).not.toBeCalled(); }); - it('loads the original image for gifs', () => { - const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' }); + it('loads the thumbnail image for static gifs', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + }); + render(PhotoViewer, { asset }); + + expect(getAssetThumbnailUrlSpy).toBeCalledWith({ + id: asset.id, + size: AssetMediaSize.Preview, + cacheKey: asset.thumbhash, + }); + expect(getAssetOriginalUrlSpy).not.toBeCalled(); + }); + + it('loads the thumbnail image for static webp images', () => { + const asset = assetFactory.build({ + originalPath: 'image.webp', + originalMimeType: 'image/webp', + type: AssetTypeEnum.Image, + }); + render(PhotoViewer, { asset }); + + expect(getAssetThumbnailUrlSpy).toBeCalledWith({ + id: asset.id, + size: AssetMediaSize.Preview, + cacheKey: asset.thumbhash, + }); + expect(getAssetOriginalUrlSpy).not.toBeCalled(); + }); + + it('loads the original image for animated gifs', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + duration: '2.0', + }); render(PhotoViewer, { asset }); expect(getAssetThumbnailUrlSpy).not.toBeCalled(); expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); }); - it('loads original for shared link when download permission is true and showMetadata permission is true', () => { - const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' }); + it('loads the original image for animated webp images', () => { + const asset = assetFactory.build({ + originalPath: 'image.webp', + originalMimeType: 'image/webp', + type: AssetTypeEnum.Image, + duration: '2.0', + }); + render(PhotoViewer, { asset }); + + expect(getAssetThumbnailUrlSpy).not.toBeCalled(); + expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); + }); + + it('not loads original static image in shared link even when download permission is true and showMetadata permission is true', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + }); + const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] }); + render(PhotoViewer, { asset, sharedLink }); + + expect(getAssetThumbnailUrlSpy).toBeCalledWith({ + id: asset.id, + size: AssetMediaSize.Preview, + cacheKey: asset.thumbhash, + }); + + expect(getAssetOriginalUrlSpy).not.toBeCalled(); + }); + + it('loads original animated image in shared link when download permission is true and showMetadata permission is true', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + duration: '2.0', + }); const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] }); render(PhotoViewer, { asset, sharedLink }); @@ -93,8 +170,13 @@ describe('PhotoViewer component', () => { expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); }); - it('not loads original image when shared link download permission is false', () => { - const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' }); + it('not loads original animated image when shared link download permission is false', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + duration: '2.0', + }); const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] }); render(PhotoViewer, { asset, sharedLink }); @@ -107,8 +189,13 @@ describe('PhotoViewer component', () => { expect(getAssetOriginalUrlSpy).not.toBeCalled(); }); - it('not loads original image when shared link showMetadata permission is false', () => { - const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' }); + it('not loads original animated image when shared link showMetadata permission is false', () => { + const asset = assetFactory.build({ + originalPath: 'image.gif', + originalMimeType: 'image/gif', + type: AssetTypeEnum.Image, + duration: '2.0', + }); const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] }); render(PhotoViewer, { asset, sharedLink }); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index d88609f7bb..e37773fca5 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -19,7 +19,7 @@ import { cancelImageUrl } from '$lib/utils/sw-messaging'; import { getAltText } from '$lib/utils/thumbnail-util'; import { toTimelineAsset } from '$lib/utils/timeline-util'; - import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; + import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; import { LoadingSpinner, toastManager } from '@immich/ui'; import { onDestroy, onMount } from 'svelte'; import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; @@ -139,7 +139,10 @@ }; // when true, will force loading of the original image - let forceUseOriginal: boolean = $derived(asset.originalMimeType === 'image/gif' || $photoZoomState.currentZoom > 1); + let forceUseOriginal: boolean = $derived( + (asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) || + $photoZoomState.currentZoom > 1, + ); const targetImageSize = $derived.by(() => { if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) { diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index dd13d613b2..261829cfc6 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -282,7 +282,7 @@ {/if} - {#if asset.isImage && asset.duration} + {#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
@@ -351,7 +351,7 @@ playbackOnIconHover={!$playVideoThumbnailOnHover} />
- {:else if asset.isImage && asset.duration && mouseOver} + {:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}