diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 9bd43a662d..1a8e31e86b 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -90,7 +90,7 @@ export class StorageCore { return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`); } - static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: ImageFormat) { + static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: 'jpeg' | 'webp') { return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}-${type}.${format}`); } diff --git a/server/src/enum.ts b/server/src/enum.ts index 9fb6168b1a..c88e2e942c 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -337,6 +337,11 @@ export enum ImageFormat { WEBP = 'webp', } +export enum RawExtractedFormat { + JPEG = 'jpeg', + JXL = 'jxl', +} + export enum LogLevel { VERBOSE = 'verbose', DEBUG = 'debug', diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 1e41dd6bb2..d0ced19a6e 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -7,7 +7,7 @@ import { Writable } from 'node:stream'; import sharp from 'sharp'; import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants'; import { Exif } from 'src/database'; -import { Colorspace, LogLevel } from 'src/enum'; +import { Colorspace, LogLevel, RawExtractedFormat } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { DecodeToBufferOptions, @@ -36,34 +36,51 @@ type ProgressEvent = { percent?: number; }; +export type ExtractResult = { + buffer: Buffer; + format: RawExtractedFormat; +}; + @Injectable() export class MediaRepository { constructor(private logger: LoggingRepository) { this.logger.setContext(MediaRepository.name); } - async extract(input: string, output: string): Promise<boolean> { + /** + * + * @param input file path to the input image + * @returns ExtractResult if succeeded, or null if failed + */ + async extract(input: string): Promise<ExtractResult | null> { try { - // remove existing output file if it exists - // as exiftool-vendored does not support overwriting via "-w!" flag - // and throws "1 files could not be read" error when the output file exists - await fs.unlink(output).catch(() => null); - await exiftool.extractBinaryTag('JpgFromRaw2', input, output); - } catch { - try { - this.logger.debug('Extracting JPEG from RAW image:', input); - await exiftool.extractJpgFromRaw(input, output); - } catch (error: any) { - this.logger.debug('Could not extract JPEG from image, trying preview', error.message); - try { - await exiftool.extractPreview(input, output); - } catch (error: any) { - this.logger.debug('Could not extract preview from image', error.message); - return false; - } - } + const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input); + return { buffer, format: RawExtractedFormat.JPEG }; + } catch (error: any) { + this.logger.debug('Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next', error.message); + } + + try { + const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input); + return { buffer, format: RawExtractedFormat.JPEG }; + } catch (error: any) { + this.logger.debug('Could not extract JPEG buffer from image, trying PreviewJXL next', error.message); + } + + try { + const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input); + return { buffer, format: RawExtractedFormat.JXL }; + } catch (error: any) { + this.logger.debug('Could not extract PreviewJXL buffer from image, trying PreviewImage next', error.message); + } + + try { + const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input); + return { buffer, format: RawExtractedFormat.JPEG }; + } catch (error: any) { + this.logger.debug('Could not extract preview buffer from image', error.message); + return null; } - return true; } async writeExif(tags: Partial<Exif>, output: string): Promise<boolean> { @@ -104,7 +121,7 @@ export class MediaRepository { } } - decodeImage(input: string, options: DecodeToBufferOptions) { + decodeImage(input: string | Buffer, options: DecodeToBufferOptions) { return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true }); } @@ -235,7 +252,7 @@ export class MediaRepository { }); } - async getImageDimensions(input: string): Promise<ImageDimensions> { + async getImageDimensions(input: string | Buffer): Promise<ImageDimensions> { const { width = 0, height = 0 } = await sharp(input).metadata(); return { width, height }; } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 089e78daa2..f39147309b 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,7 +1,6 @@ import { OutputInfo } from 'sharp'; import { SystemConfig } from 'src/config'; import { Exif } from 'src/database'; -import { AssetMediaSize } from 'src/dtos/asset-media.dto'; import { AssetFileType, AssetPathType, @@ -11,6 +10,7 @@ import { ImageFormat, JobName, JobStatus, + RawExtractedFormat, TranscodeHWAccel, TranscodePolicy, VideoCodec, @@ -231,17 +231,19 @@ describe(MediaService.name, () => { describe('handleGenerateThumbnails', () => { let rawBuffer: Buffer; let fullsizeBuffer: Buffer; + let extractedBuffer: Buffer; let rawInfo: RawImageInfo; beforeEach(() => { fullsizeBuffer = Buffer.from('embedded image data'); - rawBuffer = Buffer.from('image data'); + rawBuffer = Buffer.from('raw image data'); + extractedBuffer = Buffer.from('embedded image file'); rawInfo = { width: 100, height: 100, channels: 3 }; - mocks.media.decodeImage.mockImplementation((path) => + mocks.media.decodeImage.mockImplementation((input) => Promise.resolve( - path.includes(AssetMediaSize.FULLSIZE) - ? { data: fullsizeBuffer, info: rawInfo as OutputInfo } - : { data: rawBuffer, info: rawInfo as OutputInfo }, + typeof input === 'string' + ? { data: rawBuffer, info: rawInfo as OutputInfo } // string implies original file + : { data: fullsizeBuffer, info: rawInfo as OutputInfo }, // buffer implies embedded image extracted ), ); }); @@ -584,16 +586,15 @@ describe(MediaService.name, () => { }); it('should extract embedded image if enabled and available', async () => { - mocks.media.extract.mockResolvedValue(true); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - const convertedPath = mocks.media.extract.mock.lastCall?.[1].toString(); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(convertedPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -601,16 +602,13 @@ describe(MediaService.name, () => { }); it('should resize original image if embedded image is too small', async () => { - mocks.media.extract.mockResolvedValue(true); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - const extractedPath = mocks.media.extract.mock.lastCall?.[1].toString(); - expect(extractedPath).toMatch(/-fullsize\.jpeg$/); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, @@ -665,38 +663,40 @@ describe(MediaService.name, () => { expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, - expect.objectContaining({ processInvalidImages: true }), + expect.objectContaining({ processInvalidImages: false }), 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', ); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, - expect.objectContaining({ processInvalidImages: true }), + expect.objectContaining({ processInvalidImages: false }), 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', ); expect(mocks.media.generateThumbhash).toHaveBeenCalledOnce(); expect(mocks.media.generateThumbhash).toHaveBeenCalledWith( rawBuffer, - expect.objectContaining({ processInvalidImages: true }), + expect.objectContaining({ processInvalidImages: false }), ); expect(mocks.media.getImageDimensions).not.toHaveBeenCalled(); vi.unstubAllEnvs(); }); - it('should generate full-size preview using embedded JPEG from RAW images when extractEmbedded is true', async () => { - mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: true } }); - mocks.media.extract.mockResolvedValue(true); + it('should extract full-size JPEG preview from RAW', async () => { + mocks.systemMetadata.get.mockResolvedValue({ + 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.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - const extractedPath = mocks.media.extract.mock.lastCall?.[1].toString(); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { colorspace: Colorspace.P3, processInvalidImages: false, + size: 1440, // capped to preview size as fullsize conversion is skipped }); expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); @@ -714,9 +714,51 @@ describe(MediaService.name, () => { ); }); + it('should convert full-size WEBP preview from JXL preview of RAW', async () => { + mocks.systemMetadata.get.mockResolvedValue({ + 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.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { + colorspace: Colorspace.P3, + processInvalidImages: false, + }); + + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + fullsizeBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.WEBP, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-fullsize.webp', + ); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + fullsizeBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + ); + }); + it('should generate full-size preview directly from RAW images when extractEmbedded is false', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } }); - mocks.media.extract.mockResolvedValue(true); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); @@ -756,7 +798,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(true); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif); @@ -785,7 +827,7 @@ describe(MediaService.name, () => { it('should skip generating full-size preview for web-friendly images', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); - mocks.media.extract.mockResolvedValue(true); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); @@ -810,7 +852,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true, format: ImageFormat.WEBP, quality: 90 } }, }); - mocks.media.extract.mockResolvedValue(true); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif); @@ -2481,48 +2523,39 @@ describe(MediaService.name, () => { describe('isSRGB', () => { it('should return true for srgb colorspace', () => { - const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB' } as Exif }; - expect(sut.isSRGB(asset)).toEqual(true); + expect(sut.isSRGB({ colorspace: 'sRGB' } as Exif)).toEqual(true); }); it('should return true for srgb profile description', () => { - const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB v1.31' } as Exif }; - expect(sut.isSRGB(asset)).toEqual(true); + expect(sut.isSRGB({ profileDescription: 'sRGB v1.31' } as Exif)).toEqual(true); }); it('should return true for 8-bit image with no colorspace metadata', () => { - const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 8 } as Exif }; - expect(sut.isSRGB(asset)).toEqual(true); + expect(sut.isSRGB({ bitsPerSample: 8 } as Exif)).toEqual(true); }); it('should return true for image with no colorspace or bit depth metadata', () => { - const asset = { ...assetStub.image, exifInfo: {} as Exif }; - expect(sut.isSRGB(asset)).toEqual(true); + expect(sut.isSRGB({} as Exif)).toEqual(true); }); it('should return false for non-srgb colorspace', () => { - const asset = { ...assetStub.image, exifInfo: { colorspace: 'Adobe RGB' } as Exif }; - expect(sut.isSRGB(asset)).toEqual(false); + expect(sut.isSRGB({ colorspace: 'Adobe RGB' } as Exif)).toEqual(false); }); it('should return false for non-srgb profile description', () => { - const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sP3C' } as Exif }; - expect(sut.isSRGB(asset)).toEqual(false); + expect(sut.isSRGB({ profileDescription: 'sP3C' } as Exif)).toEqual(false); }); it('should return false for 16-bit image with no colorspace metadata', () => { - const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 16 } as Exif }; - expect(sut.isSRGB(asset)).toEqual(false); + expect(sut.isSRGB({ bitsPerSample: 16 } as Exif)).toEqual(false); }); it('should return true for 16-bit image with sRGB colorspace', () => { - const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB', bitsPerSample: 16 } as Exif }; - expect(sut.isSRGB(asset)).toEqual(true); + expect(sut.isSRGB({ colorspace: 'sRGB', bitsPerSample: 16 } as Exif)).toEqual(true); }); it('should return true for 16-bit image with sRGB profile', () => { - const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB', bitsPerSample: 16 } as Exif }; - expect(sut.isSRGB(asset)).toEqual(true); + expect(sut.isSRGB({ profileDescription: 'sRGB', bitsPerSample: 16 } as Exif)).toEqual(true); }); }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 3aeb0ed525..a0bc1b0906 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -10,11 +10,11 @@ import { AssetType, AudioCodec, Colorspace, - ImageFormat, JobName, JobStatus, LogLevel, QueueName, + RawExtractedFormat, StorageFolder, TranscodeHWAccel, TranscodePolicy, @@ -27,7 +27,6 @@ import { BaseService } from 'src/services/base.service'; import { AudioStreamInfo, DecodeToBufferOptions, - GenerateThumbnailOptions, JobItem, JobOf, VideoFormat, @@ -213,6 +212,29 @@ export class MediaService extends BaseService { return JobStatus.SUCCESS; } + private async extractImage(originalPath: string, minSize: number) { + let extracted = await this.mediaRepository.extract(originalPath); + if (extracted && !(await this.shouldUseExtractedImage(extracted.buffer, minSize))) { + extracted = null; + } + + return extracted; + } + + 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 = { + colorspace, + processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', + size: targetSize, + orientation: exifInfo.orientation ? Number(exifInfo.orientation) : undefined, + }; + + const { info, data } = await this.mediaRepository.decodeImage(thumbSource, decodeOptions); + return { info, data, colorspace }; + } + private async generateImageThumbnails(asset: { id: string; ownerId: string; @@ -225,68 +247,48 @@ export class MediaService extends BaseService { const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); this.storageCore.ensureFolders(previewPath); - const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true'; - const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; + // Handle embedded preview extraction for RAW files + const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName); + const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null; + const generateFullsize = image.fullsize.enabled && !mimeTypes.isWebSupportedImage(asset.originalPath); + const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`)); - // prevents this extra "enabled" from leaking into fullsizeOptions later - const { enabled: imageFullsizeEnabled, ...imageFullsizeConfig } = image.fullsize; + const { info, data, colorspace } = await this.decodeImage( + extracted ? extracted.buffer : asset.originalPath, + asset.exifInfo, + convertFullsize ? undefined : image.preview.size, + ); - const shouldConvertFullsize = imageFullsizeEnabled && !mimeTypes.isWebSupportedImage(asset.originalFileName); - const shouldExtractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName); - const decodeOptions: DecodeToBufferOptions = { colorspace, processInvalidImages, size: image.preview.size }; - - let useExtracted = false; - let decodeInputPath: string = asset.originalPath; - // Converted or extracted image from non-web-supported formats (e.g. RAW) - let fullsizePath: string | undefined; - - if (shouldConvertFullsize) { - // unset size to decode fullsize image - decodeOptions.size = undefined; - fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, image.fullsize.format); - } - - if (shouldExtractEmbedded) { - // For RAW files, try extracting embedded preview first - // Assume extracted image from RAW always in JPEG format, as implied from the `jpgFromRaw` tag name - const extractedPath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, ImageFormat.JPEG); - const didExtract = await this.mediaRepository.extract(asset.originalPath, extractedPath); - useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size)); - - if (useExtracted) { - if (shouldConvertFullsize) { - // skip re-encoding and directly use extracted as fullsize preview - // as usually the extracted image is already heavily compressed, no point doing lossy conversion again - fullsizePath = extractedPath; - } - // use this as origin of preview and thumbnail - decodeInputPath = extractedPath; - if (asset.exifInfo) { - // write essential orientation and colorspace EXIF for correct fullsize preview and subsequent processing - const exif = { orientation: asset.exifInfo.orientation, colorspace: asset.exifInfo.colorspace }; - await this.mediaRepository.writeExif(exif, extractedPath); - } - } - } - - const { info, data } = await this.mediaRepository.decodeImage(decodeInputPath, decodeOptions); - - const thumbnailOptions = { colorspace, processInvalidImages, raw: info }; + // generate final images + const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info }; const promises = [ this.mediaRepository.generateThumbhash(data, thumbnailOptions), this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailPath), this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewPath), ]; - // did not extract a usable image from RAW - if (fullsizePath && !useExtracted) { - const fullsizeOptions: GenerateThumbnailOptions = { - ...imageFullsizeConfig, - ...thumbnailOptions, - size: undefined, - }; + let fullsizePath: string | undefined; + + if (convertFullsize) { + // convert a new fullsize image from the same source as the thumbnail + fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, image.fullsize.format); + const fullsizeOptions = { format: image.fullsize.format, quality: image.fullsize.quality, ...thumbnailOptions }; promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath)); + } else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.JPEG) { + fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, extracted.format); + this.storageCore.ensureFolders(fullsizePath); + + // Write the buffer to disk with essential EXIF data + await this.storageRepository.createOrOverwriteFile(fullsizePath, extracted.buffer); + await this.mediaRepository.writeExif( + { + orientation: asset.exifInfo.orientation, + colorspace: asset.exifInfo.colorspace, + }, + fullsizePath, + ); } + const outputs = await Promise.all(promises); return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer }; @@ -521,8 +523,7 @@ export class MediaService extends BaseService { return name !== VideoContainer.MP4 && !ffmpegConfig.acceptedContainers.includes(name); } - isSRGB(asset: { exifInfo: Exif }): boolean { - const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo; + isSRGB({ colorspace, profileDescription, bitsPerSample }: Exif): boolean { if (colorspace || profileDescription) { return [colorspace, profileDescription].some((s) => s?.toLowerCase().includes('srgb')); } else if (bitsPerSample) { @@ -550,10 +551,9 @@ export class MediaService extends BaseService { } } - private async shouldUseExtractedImage(extractedPath: string, targetSize: number) { - const { width, height } = await this.mediaRepository.getImageDimensions(extractedPath); + private async shouldUseExtractedImage(extractedPathOrBuffer: string | Buffer, targetSize: number) { + const { width, height } = await this.mediaRepository.getImageDimensions(extractedPathOrBuffer); const extractedSize = Math.min(width, height); - return extractedSize >= targetSize; } diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index b1a9c77588..6aad418d9f 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -34,45 +34,40 @@ const raw: Record<string, string[]> = { '.x3f': ['image/x3f', 'image/x-sigma-x3f'], }; +/** + * list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg + * @TODO share with the client + * @see {@link web/src/lib/utils/asset-utils.ts#L329} + **/ +const webSupportedImage = { + '.avif': ['image/avif'], + '.gif': ['image/gif'], + '.jpeg': ['image/jpeg'], + '.jpg': ['image/jpeg'], + '.png': ['image/png', 'image/apng'], + '.webp': ['image/webp'], +}; + const image: Record<string, string[]> = { ...raw, - '.avif': ['image/avif'], + ...webSupportedImage, '.bmp': ['image/bmp'], - '.gif': ['image/gif'], '.heic': ['image/heic'], '.heif': ['image/heif'], '.hif': ['image/hif'], '.insp': ['image/jpeg'], '.jp2': ['image/jp2'], '.jpe': ['image/jpeg'], - '.jpeg': ['image/jpeg'], - '.jpg': ['image/jpeg'], '.jxl': ['image/jxl'], - '.png': ['image/png'], '.svg': ['image/svg'], '.tif': ['image/tiff'], '.tiff': ['image/tiff'], - '.webp': ['image/webp'], }; const extensionOverrides: Record<string, string> = { 'image/jpeg': '.jpg', }; -/** - * list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg - * @TODO share with the client - * @see {@link web/src/lib/utils/asset-utils.ts#L329} - **/ -const webSupportedImageMimeTypes = new Set([ - 'image/apng', - 'image/avif', - 'image/gif', - 'image/jpeg', - 'image/png', - 'image/webp', -]); - const profileExtensions = new Set(['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp', '.svg']); const profile: Record<string, string[]> = Object.fromEntries( Object.entries(image).filter(([key]) => profileExtensions.has(key)), @@ -123,7 +118,7 @@ export const mimeTypes = { isAsset: (filename: string) => isType(filename, image) || isType(filename, video), isImage: (filename: string) => isType(filename, image), - isWebSupportedImage: (filename: string) => webSupportedImageMimeTypes.has(lookup(filename)), + isWebSupportedImage: (filename: string) => isType(filename, webSupportedImage), isProfile: (filename: string) => isType(filename, profile), isSidecar: (filename: string) => isType(filename, sidecar), isVideo: (filename: string) => isType(filename, video), diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index e9f624d6bf..c6ab11aaa1 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<RepositoryInterface<MediaReposi writeExif: vitest.fn().mockImplementation(() => Promise.resolve()), generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')), decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }), - extract: vitest.fn().mockResolvedValue(false), + extract: vitest.fn().mockResolvedValue(null), probe: vitest.fn(), transcode: vitest.fn(), getImageDimensions: vitest.fn(),