diff --git a/mobile/openapi/lib/model/asset_media_size.dart b/mobile/openapi/lib/model/asset_media_size.dart index ed7a72a613..b56dbbab49 100644 --- a/mobile/openapi/lib/model/asset_media_size.dart +++ b/mobile/openapi/lib/model/asset_media_size.dart @@ -27,6 +27,7 @@ class AssetMediaSize { static const fullsize = AssetMediaSize._(r'fullsize'); static const preview = AssetMediaSize._(r'preview'); static const thumbnail = AssetMediaSize._(r'thumbnail'); + static const micro = AssetMediaSize._(r'micro'); /// List of all possible values in this [enum][AssetMediaSize]. static const values = [ @@ -34,6 +35,7 @@ class AssetMediaSize { fullsize, preview, thumbnail, + micro, ]; static AssetMediaSize? fromJson(dynamic value) => AssetMediaSizeTypeTransformer().decode(value); @@ -76,6 +78,7 @@ class AssetMediaSizeTypeTransformer { case r'fullsize': return AssetMediaSize.fullsize; case r'preview': return AssetMediaSize.preview; case r'thumbnail': return AssetMediaSize.thumbnail; + case r'micro': return AssetMediaSize.micro; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 33eaf13fc2..51023e702c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16932,7 +16932,8 @@ "original", "fullsize", "preview", - "thumbnail" + "thumbnail", + "micro" ], "type": "string" }, diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 89d0e513d8..6f0d826b6a 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -7123,7 +7123,8 @@ export enum AssetMediaSize { Original = "original", Fullsize = "fullsize", Preview = "preview", - Thumbnail = "thumbnail" + Thumbnail = "thumbnail", + Micro = "micro" } export enum SourceType { MachineLearning = "machine-learning", diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index 596273eddb..6dd8f73c92 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -14,6 +14,7 @@ export enum AssetMediaSize { FULLSIZE = 'fullsize', PREVIEW = 'preview', THUMBNAIL = 'thumbnail', + MICRO = 'micro', } const AssetMediaSizeSchema = z.enum(AssetMediaSize).describe('Asset media size').meta({ id: 'AssetMediaSize' }); diff --git a/server/src/enum.ts b/server/src/enum.ts index 9dee1db313..0d2350f7d3 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -56,6 +56,11 @@ export enum AssetFileType { FullSize = 'fullsize', Preview = 'preview', Thumbnail = 'thumbnail', + /** + * A very small thumbnail for dense, zoomed-out grids where the regular + * thumbnail would be needlessly large to fetch and decode. + */ + Micro = 'micro', Sidecar = 'sidecar', EncodedVideo = 'encoded_video', } diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index de8c2bbc91..879bbba152 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -277,6 +277,12 @@ export class AssetMediaService extends BaseService { return { targetSize: AssetMediaSize.PREVIEW }; } + if (dto.size === AssetMediaSize.MICRO && !path) { + // downgrade to the regular thumbnail when the micro size has not been + // generated yet (e.g. an existing library not yet re-thumbnailed). + return { targetSize: AssetMediaSize.THUMBNAIL }; + } + if (!path) { throw new NotFoundException('Asset media not found'); } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 994ba436ad..ac631b1fad 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -435,7 +435,7 @@ describe(MediaService.name, () => { size: 1440, }); - expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { @@ -490,6 +490,14 @@ describe(MediaService.name, () => { isProgressive: false, isTransparent: false, }, + { + assetId: asset.id, + type: AssetFileType.Micro, + path: expect.any(String), + isEdited: false, + isProgressive: false, + isTransparent: false, + }, ]); expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, thumbhash: thumbhashBuffer }); }); @@ -540,6 +548,14 @@ describe(MediaService.name, () => { isProgressive: false, isTransparent: false, }, + { + assetId: asset.id, + type: AssetFileType.Micro, + path: expect.any(String), + isEdited: false, + isProgressive: false, + isTransparent: false, + }, ]); }); @@ -589,6 +605,14 @@ describe(MediaService.name, () => { isProgressive: false, isTransparent: false, }, + { + assetId: asset.id, + type: AssetFileType.Micro, + path: expect.any(String), + isEdited: false, + isProgressive: false, + isTransparent: false, + }, ]); }); @@ -707,7 +731,7 @@ describe(MediaService.name, () => { size: 1440, }); - expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { @@ -757,7 +781,7 @@ describe(MediaService.name, () => { size: 1440, }); - expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { @@ -824,6 +848,11 @@ describe(MediaService.name, () => { isProgressive: false, isTransparent: false, }), + expect.objectContaining({ + type: AssetFileType.Micro, + isProgressive: false, + isTransparent: false, + }), ]); }); @@ -863,6 +892,11 @@ describe(MediaService.name, () => { isProgressive: true, isTransparent: false, }), + expect.objectContaining({ + type: AssetFileType.Micro, + isProgressive: false, + isTransparent: false, + }), ]); }); @@ -889,6 +923,11 @@ describe(MediaService.name, () => { isProgressive: false, isTransparent: false, }), + expect.objectContaining({ + type: AssetFileType.Micro, + isProgressive: false, + isTransparent: false, + }), ]); }); @@ -1022,7 +1061,7 @@ describe(MediaService.name, () => { expect.objectContaining({ processInvalidImages: true }), ); - expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, expect.objectContaining({ processInvalidImages: false }), @@ -1064,7 +1103,7 @@ describe(MediaService.name, () => { size: 1440, // capped to preview size as fullsize conversion is skipped }); - expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( fullsizeBuffer, { @@ -1101,7 +1140,7 @@ describe(MediaService.name, () => { processInvalidImages: false, }); - expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(4); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( fullsizeBuffer, { @@ -1149,7 +1188,7 @@ describe(MediaService.name, () => { processInvalidImages: false, }); - expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(4); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { @@ -1201,7 +1240,7 @@ describe(MediaService.name, () => { processInvalidImages: false, }); - expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(4); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { @@ -1233,7 +1272,7 @@ describe(MediaService.name, () => { size: 1440, }); - expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); expect(mocks.media.generateThumbnail).not.toHaveBeenCalledWith( expect.anything(), expect.anything(), @@ -1266,7 +1305,7 @@ describe(MediaService.name, () => { size: undefined, }); - expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(4); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { @@ -1309,7 +1348,7 @@ describe(MediaService.name, () => { processInvalidImages: false, }); - expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(4); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { @@ -1342,7 +1381,7 @@ describe(MediaService.name, () => { await sut.handleGenerateThumbnails({ id: asset.id }); - expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(4); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, expect.objectContaining({ @@ -1464,7 +1503,7 @@ describe(MediaService.name, () => { await sut.handleAssetEditThumbnailGeneration({ id: asset.id }); - expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(4); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, expect.anything(), diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index a73eb3e22e..660ab481bd 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -57,6 +57,11 @@ interface UpsertFileOptions { type ThumbnailAsset = NonNullable>>; +/// Fixed edge (px) for the micro thumbnail used by dense, zoomed-out grids. Small +/// enough that fetching/decoding thousands of them stays cheap; it reuses the +/// thumbnail's configured format and quality, only smaller. +const MICRO_THUMBNAIL_SIZE = 128; + @Injectable() export class MediaService extends BaseService { videoInterfaces: VideoInterfaces = { dri: [], mali: false }; @@ -330,16 +335,27 @@ export class MediaService extends BaseService { isProgressive: !!image.thumbnail.progressive && thumbnailFormat !== ImageFormat.Webp, isTransparent, }); + // A very small thumbnail for dense zoomed-out grids; a smaller variant of the + // thumbnail (same format/quality), so it stays tiny to fetch and decode. + const microFile = this.getImageFile(asset, { + fileType: AssetFileType.Micro, + format: thumbnailFormat, + isEdited: useEdits, + isProgressive: false, + isTransparent, + }); this.storageCore.ensureFolders(previewFile.path); // generate final images const baseOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] }; const thumbnailOptions = { ...image.thumbnail, ...baseOptions, format: thumbnailFormat }; + const microOptions = { ...image.thumbnail, ...baseOptions, format: thumbnailFormat, size: MICRO_THUMBNAIL_SIZE }; const previewOptions = { ...image.preview, ...baseOptions, format: previewFormat }; const promises = [ this.mediaRepository.generateThumbhash(data, baseOptions), this.mediaRepository.generateThumbnail(data, thumbnailOptions, thumbnailFile.path), this.mediaRepository.generateThumbnail(data, previewOptions, previewFile.path), + this.mediaRepository.generateThumbnail(data, microOptions, microFile.path), ]; let fullsizeFile: UpsertFileOptions | undefined; @@ -398,7 +414,9 @@ export class MediaService extends BaseService { const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions; return { - files: fullsizeFile ? [previewFile, thumbnailFile, fullsizeFile] : [previewFile, thumbnailFile], + files: fullsizeFile + ? [previewFile, thumbnailFile, microFile, fullsizeFile] + : [previewFile, thumbnailFile, microFile], thumbhash: outputs[0] as Buffer, fullsizeDimensions, }; @@ -520,6 +538,13 @@ export class MediaService extends BaseService { isProgressive: false, isTransparent: false, }); + const microFile = this.getImageFile(asset, { + fileType: AssetFileType.Micro, + format: image.thumbnail.format, + isEdited: false, + isProgressive: false, + isTransparent: false, + }); this.storageCore.ensureFolders(previewFile.path); const { videoStream, format } = asset; @@ -529,11 +554,14 @@ export class MediaService extends BaseService { const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() }); const thumbConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() }); + const microConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: MICRO_THUMBNAIL_SIZE.toString() }); const previewOptions = previewConfig.getCommand(TranscodeTarget.Video, videoStream, undefined, format ?? undefined); const thumbnailOptions = thumbConfig.getCommand(TranscodeTarget.Video, videoStream, undefined, format ?? undefined); + const microOptions = microConfig.getCommand(TranscodeTarget.Video, videoStream, undefined, format ?? undefined); await this.mediaRepository.transcode(asset.originalPath, previewFile.path, previewOptions); await this.mediaRepository.transcode(asset.originalPath, thumbnailFile.path, thumbnailOptions); + await this.mediaRepository.transcode(asset.originalPath, microFile.path, microOptions); const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path, { colorspace: image.colorspace, @@ -541,7 +569,7 @@ export class MediaService extends BaseService { }); return { - files: [previewFile, thumbnailFile], + files: [previewFile, thumbnailFile, microFile], thumbhash, fullsizeDimensions: { width: videoStream.width, height: videoStream.height }, };