From aaac6a46023eee1511e78e281b5500ec534bfe3e Mon Sep 17 00:00:00 2001 From: midzelis Date: Sat, 14 Feb 2026 19:50:25 +0000 Subject: [PATCH 1/3] feat: preserve alpha --- server/src/repositories/media.repository.ts | 8 ++- server/src/services/media.service.spec.ts | 42 ++++++++----- server/src/services/media.service.ts | 60 ++++++++++++------- server/src/types.ts | 1 + server/src/utils/mime-types.spec.ts | 27 +++++++++ server/src/utils/mime-types.ts | 16 +++++ .../repositories/media.repository.mock.ts | 2 +- 7 files changed, 120 insertions(+), 36 deletions(-) diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 33025e73cf6d1..057d65d910d73 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -142,7 +142,13 @@ export class MediaRepository { async decodeImage(input: string | Buffer, options: DecodeToBufferOptions) { const pipeline = await this.getImageDecodingPipeline(input, options); - return pipeline.raw().toBuffer({ resolveWithObject: true }); + let hasAlpha = false; + if (options.checkAlpha) { + const metadata = await pipeline.metadata(); + hasAlpha = metadata.hasAlpha ?? false; + } + const { data, info } = await pipeline.raw().toBuffer({ resolveWithObject: true }); + return { data, info, hasAlpha }; } private async applyEdits(pipeline: sharp.Sharp, edits: AssetEditActionItem[]): Promise { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 399eb5d6a08b1..151b2d9e7d129 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -344,8 +344,8 @@ describe(MediaService.name, () => { mocks.media.decodeImage.mockImplementation((input) => Promise.resolve( typeof input === 'string' - ? { data: rawBuffer, info: rawInfo as OutputInfo } // string implies original file - : { data: fullsizeBuffer, info: rawInfo as OutputInfo }, // buffer implies embedded image extracted + ? { data: rawBuffer, info: rawInfo as OutputInfo, hasAlpha: false } // string implies original file + : { data: fullsizeBuffer, info: rawInfo as OutputInfo, hasAlpha: false }, // buffer implies embedded image extracted ), ); }); @@ -417,6 +417,7 @@ describe(MediaService.name, () => { expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { + checkAlpha: false, colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -655,6 +656,7 @@ describe(MediaService.name, () => { expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { + checkAlpha: false, colorspace: Colorspace.Srgb, processInvalidImages: false, size: 1440, @@ -705,6 +707,7 @@ describe(MediaService.name, () => { expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { + checkAlpha: false, colorspace: Colorspace.Srgb, processInvalidImages: false, size: 1440, @@ -865,6 +868,7 @@ describe(MediaService.name, () => { expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { + checkAlpha: false, colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -883,6 +887,7 @@ describe(MediaService.name, () => { await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { + checkAlpha: false, colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -900,6 +905,7 @@ describe(MediaService.name, () => { expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { + checkAlpha: false, colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -918,6 +924,7 @@ describe(MediaService.name, () => { expect(mocks.media.extract).not.toHaveBeenCalled(); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { + checkAlpha: false, colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -977,6 +984,7 @@ describe(MediaService.name, () => { expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { + checkAlpha: false, colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, // capped to preview size as fullsize conversion is skipped @@ -1015,6 +1023,7 @@ describe(MediaService.name, () => { expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { + checkAlpha: false, colorspace: Colorspace.P3, processInvalidImages: false, }); @@ -1063,6 +1072,7 @@ describe(MediaService.name, () => { expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { + checkAlpha: false, colorspace: Colorspace.P3, processInvalidImages: false, }); @@ -1115,6 +1125,7 @@ describe(MediaService.name, () => { expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { + checkAlpha: true, colorspace: Colorspace.P3, processInvalidImages: false, }); @@ -1146,6 +1157,7 @@ describe(MediaService.name, () => { expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { + checkAlpha: false, colorspace: Colorspace.Srgb, processInvalidImages: false, size: 1440, @@ -1178,6 +1190,7 @@ describe(MediaService.name, () => { expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { + checkAlpha: true, colorspace: Colorspace.Srgb, orientation: undefined, processInvalidImages: false, @@ -1223,6 +1236,7 @@ describe(MediaService.name, () => { expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { + checkAlpha: true, colorspace: Colorspace.P3, processInvalidImages: false, }); @@ -1282,8 +1296,8 @@ describe(MediaService.name, () => { mocks.media.decodeImage.mockImplementation((input) => Promise.resolve( typeof input === 'string' - ? { data: rawBuffer, info: rawInfo as OutputInfo } // string implies original file - : { data: fullsizeBuffer, info: rawInfo as OutputInfo }, // buffer implies embedded image extracted + ? { data: rawBuffer, info: rawInfo as OutputInfo, hasAlpha: false } // string implies original file + : { data: fullsizeBuffer, info: rawInfo as OutputInfo, hasAlpha: false }, // buffer implies embedded image extracted ), ); }); @@ -1453,7 +1467,7 @@ describe(MediaService.name, () => { mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info }); + mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1498,7 +1512,7 @@ describe(MediaService.name, () => { mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info }); + mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1543,7 +1557,7 @@ describe(MediaService.name, () => { mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 2160, height: 3840 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info }); + mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1586,7 +1600,7 @@ describe(MediaService.name, () => { mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info }); + mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1629,7 +1643,7 @@ describe(MediaService.name, () => { mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 4624, height: 3080 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info }); + mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1672,7 +1686,7 @@ describe(MediaService.name, () => { mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 4624, height: 3080 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info }); + mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1718,7 +1732,7 @@ describe(MediaService.name, () => { const data = Buffer.from(''); const info = { width: 2160, height: 3840 } as OutputInfo; mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg }); - mocks.media.decodeImage.mockResolvedValue({ data, info }); + mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); mocks.media.getImageDimensions.mockResolvedValue(info); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( @@ -1762,7 +1776,7 @@ describe(MediaService.name, () => { mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 2160, height: 3840 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info }); + mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1778,7 +1792,7 @@ describe(MediaService.name, () => { mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 2160, height: 3840 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info }); + mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1800,7 +1814,7 @@ describe(MediaService.name, () => { const extracted = Buffer.from(''); const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info }); + mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue(info); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 5fa72cf1175be..66954130f9662 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -257,7 +257,7 @@ export class MediaService extends BaseService { return extracted; } - private async decodeImage(thumbSource: string | Buffer, exifInfo: Exif, targetSize?: number) { + private async decodeImage(thumbSource: string | Buffer, exifInfo: Exif, targetSize?: number, checkAlpha?: boolean) { const { image } = await this.getConfig({ withCache: true }); const colorspace = this.isSRGB(exifInfo) ? Colorspace.Srgb : image.colorspace; const decodeOptions: DecodeToBufferOptions = { @@ -265,10 +265,11 @@ export class MediaService extends BaseService { processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', size: targetSize, orientation: exifInfo.orientation ? Number(exifInfo.orientation) : undefined, + checkAlpha, }; - const { info, data } = await this.mediaRepository.decodeImage(thumbSource, decodeOptions); - return { info, data, colorspace }; + const { info, data, hasAlpha } = await this.mediaRepository.decodeImage(thumbSource, decodeOptions); + return { info, data, colorspace, hasAlpha }; } private async extractOriginalImage(asset: ThumbnailAsset, image: SystemConfig['image'], useEdits = false) { @@ -280,12 +281,14 @@ export class MediaService extends BaseService { useEdits; const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`)); - const { data, info, colorspace } = await this.decodeImage( + const checkAlpha = !extracted && mimeTypes.canHaveAlpha(asset.originalPath); + const { data, info, colorspace, hasAlpha } = await this.decodeImage( extracted ? extracted.buffer : asset.originalPath, // only specify orientation to extracted images which don't have EXIF orientation data // or it can double rotate the image extracted ? asset.exifInfo : { ...asset.exifInfo, orientation: null }, convertFullsize ? undefined : image.preview.size, + checkAlpha, ); return { @@ -295,50 +298,57 @@ export class MediaService extends BaseService { colorspace, convertFullsize, generateFullsize, + hasAlpha, }; } private async generateImageThumbnails(asset: ThumbnailAsset, { image }: SystemConfig, useEdits: boolean = false) { + // Handle embedded preview extraction for RAW files + const extractedImage = await this.extractOriginalImage(asset, image, useEdits); + const { info, data, colorspace, generateFullsize, convertFullsize, extracted, hasAlpha } = extractedImage; + + const previewFormat = this.resolveFinalImageFormat(hasAlpha, image.preview.format, asset.id); + const thumbnailFormat = this.resolveFinalImageFormat(hasAlpha, image.thumbnail.format, asset.id); + const previewFile = this.getImageFile(asset, { fileType: AssetFileType.Preview, - format: image.preview.format, + format: previewFormat, isEdited: useEdits, - isProgressive: !!image.preview.progressive && image.preview.format !== ImageFormat.Webp, + isProgressive: !!image.preview.progressive && previewFormat !== ImageFormat.Webp, }); const thumbnailFile = this.getImageFile(asset, { fileType: AssetFileType.Thumbnail, - format: image.thumbnail.format, + format: thumbnailFormat, isEdited: useEdits, - isProgressive: !!image.thumbnail.progressive && image.thumbnail.format !== ImageFormat.Webp, + isProgressive: !!image.thumbnail.progressive && thumbnailFormat !== ImageFormat.Webp, }); this.storageCore.ensureFolders(previewFile.path); - // Handle embedded preview extraction for RAW files - const extractedImage = await this.extractOriginalImage(asset, image, useEdits); - const { info, data, colorspace, generateFullsize, convertFullsize, extracted } = extractedImage; - // generate final images - const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] }; + const baseOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] }; + const thumbnailOptions = { ...image.thumbnail, ...baseOptions, format: thumbnailFormat }; + const previewOptions = { ...image.preview, ...baseOptions, format: previewFormat }; const promises = [ - this.mediaRepository.generateThumbhash(data, thumbnailOptions), - this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailFile.path), - this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewFile.path), + this.mediaRepository.generateThumbhash(data, baseOptions), + this.mediaRepository.generateThumbnail(data, thumbnailOptions, thumbnailFile.path), + this.mediaRepository.generateThumbnail(data, previewOptions, previewFile.path), ]; let fullsizeFile: UpsertFileOptions | undefined; if (convertFullsize) { + const fullsizeFormat = this.resolveFinalImageFormat(hasAlpha, image.fullsize.format, asset.id); // convert a new fullsize image from the same source as the thumbnail fullsizeFile = this.getImageFile(asset, { fileType: AssetFileType.FullSize, - format: image.fullsize.format, + format: fullsizeFormat, isEdited: useEdits, - isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp, + isProgressive: !!image.fullsize.progressive && fullsizeFormat !== ImageFormat.Webp, }); const fullsizeOptions = { - format: image.fullsize.format, + ...baseOptions, + format: fullsizeFormat, quality: image.fullsize.quality, progressive: image.fullsize.progressive, - ...thumbnailOptions, }; promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizeFile.path)); } else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) { @@ -857,6 +867,16 @@ export class MediaService extends BaseService { return generated; } + private resolveFinalImageFormat(sourceHasAlpha: boolean, format: ImageFormat, assetId: string): ImageFormat { + if (sourceHasAlpha && format === ImageFormat.Jpeg) { + this.logger.debug( + `Overriding output format from ${format} to ${ImageFormat.Webp} to preserve alpha channel for asset ${assetId}`, + ); + return ImageFormat.Webp; + } + return format; + } + private getImageFile(asset: ThumbnailPathEntity, options: ImagePathOptions & { isProgressive: boolean }) { const path = StorageCore.getImagePath(asset, options); return { diff --git a/server/src/types.ts b/server/src/types.ts index 3e9ea25957020..02bca4fdb8db8 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -62,6 +62,7 @@ type DecodeImageOptions = { export interface DecodeToBufferOptions extends DecodeImageOptions { size?: number; orientation?: ExifOrientation; + checkAlpha?: boolean; } export type GenerateThumbnailOptions = Pick & DecodeToBufferOptions; diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts index b0e31afe39786..18d506bfed2eb 100644 --- a/server/src/utils/mime-types.spec.ts +++ b/server/src/utils/mime-types.spec.ts @@ -153,6 +153,33 @@ describe('mimeTypes', () => { } }); + describe('canHaveAlpha', () => { + for (const img of [ + 'a.avif', + 'a.bmp', + 'a.gif', + 'a.heic', + 'a.heif', + 'a.hif', + 'a.jxl', + 'a.png', + 'a.svg', + 'a.tif', + 'a.tiff', + 'a.webp', + ]) { + it(`should return true for ${img}`, () => { + expect(mimeTypes.canHaveAlpha(img)).toBe(true); + }); + } + + for (const img of ['a.jpg', 'a.jpeg', 'a.jpe', 'a.insp', 'a.jp2', 'a.cr3', 'a.dng', 'a.nef', 'a.arw']) { + it(`should return false for ${img}`, () => { + expect(mimeTypes.canHaveAlpha(img)).toBe(false); + }); + } + }); + describe('animated image', () => { for (const img of ['a.avif', 'a.gif', 'a.webp']) { it('should identify animated image mime types as such', () => { diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index 4e91bbd7f1678..a4d1f60af0342 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -77,6 +77,21 @@ const extensionOverrides: Record = { 'image/jpeg': '.jpg', }; +const alphaCapableExtensions = new Set([ + '.avif', + '.bmp', + '.gif', + '.heic', + '.heif', + '.hif', + '.jxl', + '.png', + '.svg', + '.tif', + '.tiff', + '.webp', +]); + const profileExtensions = new Set(['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp', '.svg']); const profile: Record = Object.fromEntries( Object.entries(image).filter(([key]) => profileExtensions.has(key)), @@ -134,6 +149,7 @@ export const mimeTypes = { isProfile: (filename: string) => isType(filename, profile), isSidecar: (filename: string) => isType(filename, sidecar), isVideo: (filename: string) => isType(filename, video), + canHaveAlpha: (filename: string) => alphaCapableExtensions.has(extname(filename).toLowerCase()), isRaw: (filename: string) => isType(filename, raw), lookup, /** return an extension (including a leading `.`) for a mime-type */ diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index b6b1e82b52e2d..e829097128155 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -8,7 +8,7 @@ export const newMediaRepositoryMock = (): Mocked Promise.resolve()), copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()), generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')), - decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }), + decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {}, hasAlpha: false }), extract: vitest.fn().mockResolvedValue(null), probe: vitest.fn(), transcode: vitest.fn(), From d3f2e982067c11fba4c41dbd99f6ed337d3ec49d Mon Sep 17 00:00:00 2001 From: midzelis Date: Sat, 21 Feb 2026 01:56:15 +0000 Subject: [PATCH 2/3] refactor: use isTransparent naming and separate getImageMetadata --- server/src/repositories/media.repository.ts | 14 +-- server/src/services/media.service.spec.ts | 95 +++++++++++-------- server/src/services/media.service.ts | 35 +++---- server/src/types.ts | 1 - server/src/utils/mime-types.spec.ts | 6 +- server/src/utils/mime-types.ts | 4 +- .../repositories/media.repository.mock.ts | 4 +- 7 files changed, 85 insertions(+), 74 deletions(-) diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 057d65d910d73..e3e78b3238d68 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -142,13 +142,7 @@ export class MediaRepository { async decodeImage(input: string | Buffer, options: DecodeToBufferOptions) { const pipeline = await this.getImageDecodingPipeline(input, options); - let hasAlpha = false; - if (options.checkAlpha) { - const metadata = await pipeline.metadata(); - hasAlpha = metadata.hasAlpha ?? false; - } - const { data, info } = await pipeline.raw().toBuffer({ resolveWithObject: true }); - return { data, info, hasAlpha }; + return pipeline.raw().toBuffer({ resolveWithObject: true }); } private async applyEdits(pipeline: sharp.Sharp, edits: AssetEditActionItem[]): Promise { @@ -315,9 +309,9 @@ export class MediaRepository { }); } - async getImageDimensions(input: string | Buffer): Promise { - const { width = 0, height = 0 } = await sharp(input).metadata(); - return { width, height }; + async getImageMetadata(input: string | Buffer): Promise { + const { width = 0, height = 0, hasAlpha = false } = await sharp(input).metadata(); + return { width, height, isTransparent: hasAlpha }; } private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeCommand) { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 151b2d9e7d129..368ece625ce43 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -344,10 +344,11 @@ describe(MediaService.name, () => { mocks.media.decodeImage.mockImplementation((input) => Promise.resolve( typeof input === 'string' - ? { data: rawBuffer, info: rawInfo as OutputInfo, hasAlpha: false } // string implies original file - : { data: fullsizeBuffer, info: rawInfo as OutputInfo, hasAlpha: false }, // buffer implies embedded image extracted + ? { data: rawBuffer, info: rawInfo as OutputInfo } // string implies original file + : { data: fullsizeBuffer, info: rawInfo as OutputInfo }, // buffer implies embedded image extracted ), ); + mocks.media.getImageMetadata.mockResolvedValue({ width: 100, height: 100, isTransparent: false }); }); it('should skip thumbnail generation if asset not found', async () => { @@ -417,7 +418,6 @@ describe(MediaService.name, () => { expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { - checkAlpha: false, colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -656,7 +656,6 @@ describe(MediaService.name, () => { expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { - checkAlpha: false, colorspace: Colorspace.Srgb, processInvalidImages: false, size: 1440, @@ -707,7 +706,6 @@ describe(MediaService.name, () => { expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { - checkAlpha: false, colorspace: Colorspace.Srgb, processInvalidImages: false, size: 1440, @@ -860,7 +858,7 @@ describe(MediaService.name, () => { .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) .build(); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); @@ -868,26 +866,51 @@ describe(MediaService.name, () => { expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { - checkAlpha: false, colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); }); + it('should not check transparency metadata for raw files without extracted images', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + + await sut.handleGenerateThumbnails({ id: asset.id }); + + expect(mocks.media.getImageMetadata).not.toHaveBeenCalled(); + }); + + it('should not check transparency metadata for raw files with extracted images', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); + mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + + await sut.handleGenerateThumbnails({ id: asset.id }); + + expect(mocks.media.getImageMetadata).toHaveBeenCalledOnce(); + expect(mocks.media.getImageMetadata).toHaveBeenCalledWith(extractedBuffer); + }); + it('should resize original image if embedded image is too small', async () => { const asset = AssetFactory.from({ originalFileName: 'file.dng' }) .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) .build(); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { - checkAlpha: false, colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -905,7 +928,6 @@ describe(MediaService.name, () => { expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { - checkAlpha: false, colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -924,7 +946,6 @@ describe(MediaService.name, () => { expect(mocks.media.extract).not.toHaveBeenCalled(); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { - checkAlpha: false, colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -977,14 +998,13 @@ describe(MediaService.name, () => { image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true }, }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { - checkAlpha: false, colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, // capped to preview size as fullsize conversion is skipped @@ -1016,14 +1036,13 @@ describe(MediaService.name, () => { image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true }, }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jxl }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { - checkAlpha: false, colorspace: Colorspace.P3, processInvalidImages: false, }); @@ -1065,14 +1084,13 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { - checkAlpha: false, colorspace: Colorspace.P3, processInvalidImages: false, }); @@ -1110,7 +1128,7 @@ describe(MediaService.name, () => { it('should generate full-size preview from non-web-friendly images', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. const asset = AssetFactory.from({ originalFileName: 'image.hif' }) .exif({ @@ -1125,7 +1143,6 @@ describe(MediaService.name, () => { expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { - checkAlpha: true, colorspace: Colorspace.P3, processInvalidImages: false, }); @@ -1150,14 +1167,13 @@ describe(MediaService.name, () => { const asset = AssetFactory.from().exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { - checkAlpha: false, colorspace: Colorspace.Srgb, processInvalidImages: false, size: 1440, @@ -1174,7 +1190,7 @@ describe(MediaService.name, () => { it('should always generate full-size preview from non-web-friendly panoramas', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.media.copyTagGroup.mockResolvedValue(true); const asset = AssetFactory.from({ originalFileName: 'panorama.tif' }) @@ -1190,7 +1206,6 @@ describe(MediaService.name, () => { expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { - checkAlpha: true, colorspace: Colorspace.Srgb, orientation: undefined, processInvalidImages: false, @@ -1221,7 +1236,7 @@ describe(MediaService.name, () => { image: { fullsize: { enabled: true, format: ImageFormat.Webp, quality: 90 } }, }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. const asset = AssetFactory.from({ originalFileName: 'image.hif' }) .exif({ @@ -1236,7 +1251,6 @@ describe(MediaService.name, () => { expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { - checkAlpha: true, colorspace: Colorspace.P3, processInvalidImages: false, }); @@ -1262,7 +1276,7 @@ describe(MediaService.name, () => { image: { fullsize: { enabled: true, format: ImageFormat.Jpeg, progressive: true } }, }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); const asset = AssetFactory.from({ originalFileName: 'image.hif' }) .exif({ fileSizeInByte: 5000, @@ -1296,10 +1310,11 @@ describe(MediaService.name, () => { mocks.media.decodeImage.mockImplementation((input) => Promise.resolve( typeof input === 'string' - ? { data: rawBuffer, info: rawInfo as OutputInfo, hasAlpha: false } // string implies original file - : { data: fullsizeBuffer, info: rawInfo as OutputInfo, hasAlpha: false }, // buffer implies embedded image extracted + ? { data: rawBuffer, info: rawInfo as OutputInfo } // string implies original file + : { data: fullsizeBuffer, info: rawInfo as OutputInfo }, // buffer implies embedded image extracted ), ); + mocks.media.getImageMetadata.mockResolvedValue({ width: 100, height: 100, isTransparent: false }); }); it('should skip videos', async () => { @@ -1467,7 +1482,7 @@ describe(MediaService.name, () => { mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); + mocks.media.decodeImage.mockResolvedValue({ data, info }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1512,7 +1527,7 @@ describe(MediaService.name, () => { mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); + mocks.media.decodeImage.mockResolvedValue({ data, info }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1557,7 +1572,7 @@ describe(MediaService.name, () => { mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 2160, height: 3840 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); + mocks.media.decodeImage.mockResolvedValue({ data, info }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1600,7 +1615,7 @@ describe(MediaService.name, () => { mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); + mocks.media.decodeImage.mockResolvedValue({ data, info }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1643,7 +1658,7 @@ describe(MediaService.name, () => { mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 4624, height: 3080 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); + mocks.media.decodeImage.mockResolvedValue({ data, info }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1686,7 +1701,7 @@ describe(MediaService.name, () => { mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 4624, height: 3080 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); + mocks.media.decodeImage.mockResolvedValue({ data, info }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1732,8 +1747,8 @@ describe(MediaService.name, () => { const data = Buffer.from(''); const info = { width: 2160, height: 3840 } as OutputInfo; mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg }); - mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); - mocks.media.getImageDimensions.mockResolvedValue(info); + mocks.media.decodeImage.mockResolvedValue({ data, info }); + mocks.media.getImageMetadata.mockResolvedValue({ width: 2160, height: 3840, isTransparent: false }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1776,7 +1791,7 @@ describe(MediaService.name, () => { mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 2160, height: 3840 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); + mocks.media.decodeImage.mockResolvedValue({ data, info }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1792,7 +1807,7 @@ describe(MediaService.name, () => { mocks.media.generateThumbnail.mockResolvedValue(); const data = Buffer.from(''); const info = { width: 2160, height: 3840 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); + mocks.media.decodeImage.mockResolvedValue({ data, info }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, @@ -1814,9 +1829,9 @@ describe(MediaService.name, () => { const extracted = Buffer.from(''); const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; - mocks.media.decodeImage.mockResolvedValue({ data, info, hasAlpha: false }); + mocks.media.decodeImage.mockResolvedValue({ data, info }); mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg }); - mocks.media.getImageDimensions.mockResolvedValue(info); + mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( JobStatus.Success, diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 66954130f9662..baacf575025d7 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -257,7 +257,7 @@ export class MediaService extends BaseService { return extracted; } - private async decodeImage(thumbSource: string | Buffer, exifInfo: Exif, targetSize?: number, checkAlpha?: boolean) { + private async decodeImage(thumbSource: string | Buffer, exifInfo: Exif, targetSize?: number) { const { image } = await this.getConfig({ withCache: true }); const colorspace = this.isSRGB(exifInfo) ? Colorspace.Srgb : image.colorspace; const decodeOptions: DecodeToBufferOptions = { @@ -265,11 +265,10 @@ export class MediaService extends BaseService { processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', size: targetSize, orientation: exifInfo.orientation ? Number(exifInfo.orientation) : undefined, - checkAlpha, }; - const { info, data, hasAlpha } = await this.mediaRepository.decodeImage(thumbSource, decodeOptions); - return { info, data, colorspace, hasAlpha }; + const { info, data } = await this.mediaRepository.decodeImage(thumbSource, decodeOptions); + return { info, data, colorspace }; } private async extractOriginalImage(asset: ThumbnailAsset, image: SystemConfig['image'], useEdits = false) { @@ -281,16 +280,20 @@ export class MediaService extends BaseService { useEdits; const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`)); - const checkAlpha = !extracted && mimeTypes.canHaveAlpha(asset.originalPath); - const { data, info, colorspace, hasAlpha } = await this.decodeImage( - extracted ? extracted.buffer : asset.originalPath, + const thumbSource = extracted ? extracted.buffer : asset.originalPath; + const { data, info, colorspace } = await this.decodeImage( + thumbSource, // only specify orientation to extracted images which don't have EXIF orientation data // or it can double rotate the image extracted ? asset.exifInfo : { ...asset.exifInfo, orientation: null }, convertFullsize ? undefined : image.preview.size, - checkAlpha, ); + let isTransparent = false; + if (!extracted && mimeTypes.canBeTransparent(asset.originalPath)) { + ({ isTransparent } = await this.mediaRepository.getImageMetadata(asset.originalPath)); + } + return { extracted, data, @@ -298,17 +301,17 @@ export class MediaService extends BaseService { colorspace, convertFullsize, generateFullsize, - hasAlpha, + isTransparent, }; } private async generateImageThumbnails(asset: ThumbnailAsset, { image }: SystemConfig, useEdits: boolean = false) { // Handle embedded preview extraction for RAW files const extractedImage = await this.extractOriginalImage(asset, image, useEdits); - const { info, data, colorspace, generateFullsize, convertFullsize, extracted, hasAlpha } = extractedImage; + const { info, data, colorspace, generateFullsize, convertFullsize, extracted, isTransparent } = extractedImage; - const previewFormat = this.resolveFinalImageFormat(hasAlpha, image.preview.format, asset.id); - const thumbnailFormat = this.resolveFinalImageFormat(hasAlpha, image.thumbnail.format, asset.id); + const previewFormat = this.resolveFinalImageFormat(isTransparent, image.preview.format, asset.id); + const thumbnailFormat = this.resolveFinalImageFormat(isTransparent, image.thumbnail.format, asset.id); const previewFile = this.getImageFile(asset, { fileType: AssetFileType.Preview, @@ -336,7 +339,7 @@ export class MediaService extends BaseService { let fullsizeFile: UpsertFileOptions | undefined; if (convertFullsize) { - const fullsizeFormat = this.resolveFinalImageFormat(hasAlpha, image.fullsize.format, asset.id); + const fullsizeFormat = this.resolveFinalImageFormat(isTransparent, image.fullsize.format, asset.id); // convert a new fullsize image from the same source as the thumbnail fullsizeFile = this.getImageFile(asset, { fileType: AssetFileType.FullSize, @@ -768,7 +771,7 @@ export class MediaService extends BaseService { } private async shouldUseExtractedImage(extractedPathOrBuffer: string | Buffer, targetSize: number) { - const { width, height } = await this.mediaRepository.getImageDimensions(extractedPathOrBuffer); + const { width, height } = await this.mediaRepository.getImageMetadata(extractedPathOrBuffer); const extractedSize = Math.min(width, height); return extractedSize >= targetSize; } @@ -867,8 +870,8 @@ export class MediaService extends BaseService { return generated; } - private resolveFinalImageFormat(sourceHasAlpha: boolean, format: ImageFormat, assetId: string): ImageFormat { - if (sourceHasAlpha && format === ImageFormat.Jpeg) { + private resolveFinalImageFormat(isTransparent: boolean, format: ImageFormat, assetId: string): ImageFormat { + if (isTransparent && format === ImageFormat.Jpeg) { this.logger.debug( `Overriding output format from ${format} to ${ImageFormat.Webp} to preserve alpha channel for asset ${assetId}`, ); diff --git a/server/src/types.ts b/server/src/types.ts index 02bca4fdb8db8..3e9ea25957020 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -62,7 +62,6 @@ type DecodeImageOptions = { export interface DecodeToBufferOptions extends DecodeImageOptions { size?: number; orientation?: ExifOrientation; - checkAlpha?: boolean; } export type GenerateThumbnailOptions = Pick & DecodeToBufferOptions; diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts index 18d506bfed2eb..862ed310bc1cb 100644 --- a/server/src/utils/mime-types.spec.ts +++ b/server/src/utils/mime-types.spec.ts @@ -153,7 +153,7 @@ describe('mimeTypes', () => { } }); - describe('canHaveAlpha', () => { + describe('canBeTransparent', () => { for (const img of [ 'a.avif', 'a.bmp', @@ -169,13 +169,13 @@ describe('mimeTypes', () => { 'a.webp', ]) { it(`should return true for ${img}`, () => { - expect(mimeTypes.canHaveAlpha(img)).toBe(true); + expect(mimeTypes.canBeTransparent(img)).toBe(true); }); } for (const img of ['a.jpg', 'a.jpeg', 'a.jpe', 'a.insp', 'a.jp2', 'a.cr3', 'a.dng', 'a.nef', 'a.arw']) { it(`should return false for ${img}`, () => { - expect(mimeTypes.canHaveAlpha(img)).toBe(false); + expect(mimeTypes.canBeTransparent(img)).toBe(false); }); } }); diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index a4d1f60af0342..43421e79373ec 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -77,7 +77,7 @@ const extensionOverrides: Record = { 'image/jpeg': '.jpg', }; -const alphaCapableExtensions = new Set([ +const transparentCapableExtensions = new Set([ '.avif', '.bmp', '.gif', @@ -149,7 +149,7 @@ export const mimeTypes = { isProfile: (filename: string) => isType(filename, profile), isSidecar: (filename: string) => isType(filename, sidecar), isVideo: (filename: string) => isType(filename, video), - canHaveAlpha: (filename: string) => alphaCapableExtensions.has(extname(filename).toLowerCase()), + canBeTransparent: (filename: string) => transparentCapableExtensions.has(extname(filename).toLowerCase()), isRaw: (filename: string) => isType(filename, raw), lookup, /** return an extension (including a leading `.`) for a mime-type */ diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index e829097128155..bd8deb4b3a308 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -8,10 +8,10 @@ export const newMediaRepositoryMock = (): Mocked Promise.resolve()), copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()), generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')), - decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {}, hasAlpha: false }), + decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }), extract: vitest.fn().mockResolvedValue(null), probe: vitest.fn(), transcode: vitest.fn(), - getImageDimensions: vitest.fn(), + getImageMetadata: vitest.fn(), }; }; From 62168597237a1bb78f2d1d34d8f6b61a62ccb87a Mon Sep 17 00:00:00 2001 From: midzelis Date: Sat, 21 Feb 2026 01:58:55 +0000 Subject: [PATCH 3/3] warn instead of preserve --- server/src/services/media.service.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index baacf575025d7..153083142ddfb 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -310,8 +310,11 @@ export class MediaService extends BaseService { const extractedImage = await this.extractOriginalImage(asset, image, useEdits); const { info, data, colorspace, generateFullsize, convertFullsize, extracted, isTransparent } = extractedImage; - const previewFormat = this.resolveFinalImageFormat(isTransparent, image.preview.format, asset.id); - const thumbnailFormat = this.resolveFinalImageFormat(isTransparent, image.thumbnail.format, asset.id); + const previewFormat = image.preview.format; + this.warnOnTransparencyLoss(isTransparent, previewFormat, asset.id); + + const thumbnailFormat = image.thumbnail.format; + this.warnOnTransparencyLoss(isTransparent, thumbnailFormat, asset.id); const previewFile = this.getImageFile(asset, { fileType: AssetFileType.Preview, @@ -339,7 +342,8 @@ export class MediaService extends BaseService { let fullsizeFile: UpsertFileOptions | undefined; if (convertFullsize) { - const fullsizeFormat = this.resolveFinalImageFormat(isTransparent, image.fullsize.format, asset.id); + const fullsizeFormat = image.fullsize.format; + this.warnOnTransparencyLoss(isTransparent, fullsizeFormat, asset.id); // convert a new fullsize image from the same source as the thumbnail fullsizeFile = this.getImageFile(asset, { fileType: AssetFileType.FullSize, @@ -870,14 +874,12 @@ export class MediaService extends BaseService { return generated; } - private resolveFinalImageFormat(isTransparent: boolean, format: ImageFormat, assetId: string): ImageFormat { + private warnOnTransparencyLoss(isTransparent: boolean, format: ImageFormat, assetId: string) { if (isTransparent && format === ImageFormat.Jpeg) { - this.logger.debug( - `Overriding output format from ${format} to ${ImageFormat.Webp} to preserve alpha channel for asset ${assetId}`, + this.logger.warn( + `Asset ${assetId} has transparency but the configured format is ${format} which does not support it, consider using a format that does, such as ${ImageFormat.Webp}`, ); - return ImageFormat.Webp; } - return format; } private getImageFile(asset: ThumbnailPathEntity, options: ImagePathOptions & { isProgressive: boolean }) {