diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 090eebdf85..ced0230f3e 100644 Binary files a/mobile/openapi/lib/model/asset_response_dto.dart and b/mobile/openapi/lib/model/asset_response_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c2850b3a28..84a1a7a1ce 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7708,6 +7708,9 @@ "originalFileName": { "type": "string" }, + "originalMimeType": { + "type": "string" + }, "originalPath": { "type": "string" }, @@ -7782,6 +7785,7 @@ "isTrashed", "localDateTime", "originalFileName", + "originalMimeType", "originalPath", "ownerId", "resized", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 168430bc84..d772cb6245 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -182,6 +182,7 @@ export type AssetResponseDto = { livePhotoVideoId?: string | null; localDateTime: string; originalFileName: string; + originalMimeType: string; originalPath: string; owner?: UserResponseDto; ownerId: string; diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index a0095f9543..d75a0c6329 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -13,12 +13,14 @@ import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity, AssetType } from 'src/entities/asset.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; +import { mimeTypes } from 'src/utils/mime-types'; export class SanitizedAssetResponseDto { id!: string; @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType }) type!: AssetType; thumbhash!: string | null; + originalMimeType!: string; resized!: boolean; localDateTime!: Date; duration!: string; @@ -87,6 +89,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As const sanitizedAssetResponse: SanitizedAssetResponseDto = { id: entity.id, type: entity.type, + originalMimeType: mimeTypes.lookup(entity.originalFileName), thumbhash: entity.thumbhash?.toString('base64') ?? null, localDateTime: entity.localDateTime, resized: !!entity.previewPath, @@ -107,6 +110,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As type: entity.type, originalPath: entity.originalPath, originalFileName: entity.originalFileName, + originalMimeType: mimeTypes.lookup(entity.originalFileName), resized: !!entity.previewPath, thumbhash: entity.thumbhash?.toString('base64') ?? null, fileCreatedAt: entity.fileCreatedAt, diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index acea121609..4d661bc571 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -52,6 +52,7 @@ const assetResponse: AssetResponseDto = { ownerId: 'user_id_1', deviceId: 'device_id_1', type: AssetType.VIDEO, + originalMimeType: 'image/jpeg', originalPath: 'fake_path/jpeg', originalFileName: 'asset_1.jpeg', resized: false, @@ -82,6 +83,7 @@ const assetResponse: AssetResponseDto = { const assetResponseWithoutMetadata = { id: 'id_1', type: AssetType.VIDEO, + originalMimeType: 'image/jpeg', resized: false, thumbhash: null, localDateTime: today, diff --git a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts new file mode 100644 index 0000000000..81741cc489 --- /dev/null +++ b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts @@ -0,0 +1,49 @@ +import PhotoViewer from '$lib/components/asset-viewer/photo-viewer.svelte'; +import * as utils from '$lib/utils'; +import { AssetMediaSize } from '@immich/sdk'; +import { assetFactory } from '@test-data/factories/asset-factory'; +import { render } from '@testing-library/svelte'; +import type { MockInstance } from 'vitest'; + +vi.mock('$lib/utils', async (originalImport) => { + const meta = await originalImport(); + return { + ...meta, + getAssetOriginalUrl: vi.fn(), + getAssetThumbnailUrl: vi.fn(), + }; +}); + +describe('PhotoViewer component', () => { + let getAssetOriginalUrlSpy: MockInstance; + let getAssetThumbnailUrlSpy: MockInstance; + + beforeAll(() => { + getAssetOriginalUrlSpy = vi.spyOn(utils, 'getAssetOriginalUrl'); + getAssetThumbnailUrlSpy = vi.spyOn(utils, 'getAssetThumbnailUrl'); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('loads the thumbnail', () => { + const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' }); + render(PhotoViewer, { asset }); + + expect(getAssetThumbnailUrlSpy).toBeCalledWith({ + id: asset.id, + size: AssetMediaSize.Preview, + checksum: asset.checksum, + }); + expect(getAssetOriginalUrlSpy).not.toBeCalled(); + }); + + it('loads the original image for gifs', () => { + const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' }); + render(PhotoViewer, { asset }); + + expect(getAssetThumbnailUrlSpy).not.toBeCalled(); + expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, checksum: asset.checksum }); + }); +}); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 384b3ebc19..47d92b0d53 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -38,7 +38,8 @@ $: useOriginalByDefault = isWebCompatible && $alwaysLoadOriginalFile; $: useOriginalImage = useOriginalByDefault || forceUseOriginal; // when true, will force loading of the original image - $: forceUseOriginal = forceUseOriginal || ($photoZoomState.currentZoom > 1 && isWebCompatible); + $: forceUseOriginal = + forceUseOriginal || asset.originalMimeType === 'image/gif' || ($photoZoomState.currentZoom > 1 && isWebCompatible); $: preload(useOriginalImage, preloadAssets); $: imageLoaderUrl = getAssetUrl(asset.id, useOriginalImage, asset.checksum); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index b6624770ad..9fa851aa39 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -257,20 +257,20 @@ export function getAssetRatio(asset: AssetResponseDto) { } // list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg -const supportedImageExtensions = new Set(['apng', 'avif', 'gif', 'jpg', 'jpeg', 'jfif', 'pjpeg', 'pjp', 'png', 'webp']); +const supportedImageMimeTypes = new Set([ + 'image/apng', + 'image/avif', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp', +]); /** * Returns true if the asset is an image supported by web browsers, false otherwise */ export function isWebCompatibleImage(asset: AssetResponseDto): boolean { - // originalPath is undefined when public shared link has metadata option turned off - if (!asset.originalPath) { - return false; - } - - const imgExtension = getFilenameExtension(asset.originalPath); - - return supportedImageExtensions.has(imgExtension); + return supportedImageMimeTypes.has(asset.originalMimeType); } export const getAssetType = (type: AssetTypeEnum) => { diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index 6b1e1a6ce3..e76138fe59 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -11,6 +11,7 @@ export const assetFactory = Sync.makeFactory({ type: Sync.each(() => faker.helpers.enumValue(AssetTypeEnum)), originalPath: Sync.each(() => faker.system.filePath()), originalFileName: Sync.each(() => faker.system.fileName()), + originalMimeType: Sync.each(() => faker.system.mimeType()), resized: true, thumbhash: Sync.each(() => faker.string.alphanumeric(28)), fileCreatedAt: Sync.each(() => faker.date.past().toISOString()),