diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 870035625..100c2e8cd 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -1257,6 +1257,7 @@ describe('/asset', () => { for (const { id, status } of assets) { expect(status).toBe(AssetMediaStatus.Created); + // longer timeout as the thumbnail generation from full-size raw files can take a while await utils.waitForWebsocketEvent({ event: 'assetUpload', id }); } diff --git a/e2e/src/web/specs/photo-viewer.e2e-spec.ts b/e2e/src/web/specs/photo-viewer.e2e-spec.ts index 09340e98c..37e691625 100644 --- a/e2e/src/web/specs/photo-viewer.e2e-spec.ts +++ b/e2e/src/web/specs/photo-viewer.e2e-spec.ts @@ -44,7 +44,7 @@ test.describe('Photo Viewer', () => { const { x, y, width, height } = box!; await page.mouse.move(x + width / 2, y + height / 2); await page.mouse.wheel(0, -1); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original'); + await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize'); }); test('reloads photo when checksum changes', async ({ page }) => { diff --git a/i18n/en.json b/i18n/en.json index e08c8ec54..8d1cc3a2b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -65,8 +65,13 @@ "forcing_refresh_library_files": "Forcing refresh of all library files", "image_format": "Format", "image_format_description": "WebP produces smaller files than JPEG, but is slower to encode.", + "image_fullsize_enabled": "Enable full-size image generation", + "image_fullsize_enabled_description": "Generate full-size image for non-web-friendly formats. When \"Prefer embedded preview\" is enabled, embedded previews are used directly without conversion. Does not affect web-friendly formats like JPEG.", + "image_fullsize_quality_description": "Full-size image quality from 1-100. Higher is better, but produces larger files.", + "image_fullsize_title": "Full-size Image Settings", + "image_fullsize_description": "Full-size image with stripped metadata, used when zoomed in", "image_prefer_embedded_preview": "Prefer embedded preview", - "image_prefer_embedded_preview_setting_description": "Use embedded previews in RAW photos as the input to image processing when available. This can produce more accurate colors for some images, but the quality of the preview is camera-dependent and the image may have more compression artifacts.", + "image_prefer_embedded_preview_setting_description": "Use embedded previews in RAW photos as the input to image processing and when available. This can produce more accurate colors for some images, but the quality of the preview is camera-dependent and the image may have more compression artifacts.", "image_prefer_wide_gamut": "Prefer wide gamut", "image_prefer_wide_gamut_setting_description": "Use Display P3 for thumbnails. This better preserves the vibrance of images with wide colorspaces, but images may appear differently on old devices with an old browser version. sRGB images are kept as sRGB to avoid color shifts.", "image_preview_description": "Medium-size image with stripped metadata, used when viewing a single asset and for machine learning", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index dfeda7adf..1926843dd 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -438,6 +438,7 @@ Class | Method | HTTP request | Description - [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) - [SystemConfigFacesDto](doc//SystemConfigFacesDto.md) + - [SystemConfigGeneratedFullsizeImageDto](doc//SystemConfigGeneratedFullsizeImageDto.md) - [SystemConfigGeneratedImageDto](doc//SystemConfigGeneratedImageDto.md) - [SystemConfigImageDto](doc//SystemConfigImageDto.md) - [SystemConfigJobDto](doc//SystemConfigJobDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index eddd63a73..3986362c9 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -245,6 +245,7 @@ part 'model/system_config_backups_dto.dart'; part 'model/system_config_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart'; part 'model/system_config_faces_dto.dart'; +part 'model/system_config_generated_fullsize_image_dto.dart'; part 'model/system_config_generated_image_dto.dart'; part 'model/system_config_image_dto.dart'; part 'model/system_config_job_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 783e5e375..5759217f4 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -546,6 +546,8 @@ class ApiClient { return SystemConfigFFmpegDto.fromJson(value); case 'SystemConfigFacesDto': return SystemConfigFacesDto.fromJson(value); + case 'SystemConfigGeneratedFullsizeImageDto': + return SystemConfigGeneratedFullsizeImageDto.fromJson(value); case 'SystemConfigGeneratedImageDto': return SystemConfigGeneratedImageDto.fromJson(value); case 'SystemConfigImageDto': diff --git a/mobile/openapi/lib/model/asset_media_size.dart b/mobile/openapi/lib/model/asset_media_size.dart index 2a950db41..aa7e2a6f5 100644 --- a/mobile/openapi/lib/model/asset_media_size.dart +++ b/mobile/openapi/lib/model/asset_media_size.dart @@ -23,11 +23,13 @@ class AssetMediaSize { String toJson() => value; + static const fullsize = AssetMediaSize._(r'fullsize'); static const preview = AssetMediaSize._(r'preview'); static const thumbnail = AssetMediaSize._(r'thumbnail'); /// List of all possible values in this [enum][AssetMediaSize]. static const values = [ + fullsize, preview, thumbnail, ]; @@ -68,6 +70,7 @@ class AssetMediaSizeTypeTransformer { AssetMediaSize? decode(dynamic data, {bool allowNull = true}) { if (data != null) { switch (data) { + case r'fullsize': return AssetMediaSize.fullsize; case r'preview': return AssetMediaSize.preview; case r'thumbnail': return AssetMediaSize.thumbnail; default: diff --git a/mobile/openapi/lib/model/path_type.dart b/mobile/openapi/lib/model/path_type.dart index bfb16c666..55453ed1e 100644 --- a/mobile/openapi/lib/model/path_type.dart +++ b/mobile/openapi/lib/model/path_type.dart @@ -24,6 +24,7 @@ class PathType { String toJson() => value; static const original = PathType._(r'original'); + static const fullsize = PathType._(r'fullsize'); static const preview = PathType._(r'preview'); static const thumbnail = PathType._(r'thumbnail'); static const encodedVideo = PathType._(r'encoded_video'); @@ -34,6 +35,7 @@ class PathType { /// List of all possible values in this [enum][PathType]. static const values = [ original, + fullsize, preview, thumbnail, encodedVideo, @@ -79,6 +81,7 @@ class PathTypeTypeTransformer { if (data != null) { switch (data) { case r'original': return PathType.original; + case r'fullsize': return PathType.fullsize; case r'preview': return PathType.preview; case r'thumbnail': return PathType.thumbnail; case r'encoded_video': return PathType.encodedVideo; diff --git a/mobile/openapi/lib/model/system_config_generated_fullsize_image_dto.dart b/mobile/openapi/lib/model/system_config_generated_fullsize_image_dto.dart new file mode 100644 index 000000000..fbeb704b2 --- /dev/null +++ b/mobile/openapi/lib/model/system_config_generated_fullsize_image_dto.dart @@ -0,0 +1,117 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SystemConfigGeneratedFullsizeImageDto { + /// Returns a new [SystemConfigGeneratedFullsizeImageDto] instance. + SystemConfigGeneratedFullsizeImageDto({ + required this.enabled, + required this.format, + required this.quality, + }); + + bool enabled; + + ImageFormat format; + + /// Minimum value: 1 + /// Maximum value: 100 + int quality; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedFullsizeImageDto && + other.enabled == enabled && + other.format == format && + other.quality == quality; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled.hashCode) + + (format.hashCode) + + (quality.hashCode); + + @override + String toString() => 'SystemConfigGeneratedFullsizeImageDto[enabled=$enabled, format=$format, quality=$quality]'; + + Map toJson() { + final json = {}; + json[r'enabled'] = this.enabled; + json[r'format'] = this.format; + json[r'quality'] = this.quality; + return json; + } + + /// Returns a new [SystemConfigGeneratedFullsizeImageDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigGeneratedFullsizeImageDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigGeneratedFullsizeImageDto"); + if (value is Map) { + final json = value.cast(); + + return SystemConfigGeneratedFullsizeImageDto( + enabled: mapValueOfType(json, r'enabled')!, + format: ImageFormat.fromJson(json[r'format'])!, + quality: mapValueOfType(json, r'quality')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SystemConfigGeneratedFullsizeImageDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SystemConfigGeneratedFullsizeImageDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigGeneratedFullsizeImageDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SystemConfigGeneratedFullsizeImageDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'enabled', + 'format', + 'quality', + }; +} + diff --git a/mobile/openapi/lib/model/system_config_image_dto.dart b/mobile/openapi/lib/model/system_config_image_dto.dart index 5309f7745..783eaa7d4 100644 --- a/mobile/openapi/lib/model/system_config_image_dto.dart +++ b/mobile/openapi/lib/model/system_config_image_dto.dart @@ -15,6 +15,7 @@ class SystemConfigImageDto { SystemConfigImageDto({ required this.colorspace, required this.extractEmbedded, + required this.fullsize, required this.preview, required this.thumbnail, }); @@ -23,6 +24,8 @@ class SystemConfigImageDto { bool extractEmbedded; + SystemConfigGeneratedFullsizeImageDto fullsize; + SystemConfigGeneratedImageDto preview; SystemConfigGeneratedImageDto thumbnail; @@ -31,6 +34,7 @@ class SystemConfigImageDto { bool operator ==(Object other) => identical(this, other) || other is SystemConfigImageDto && other.colorspace == colorspace && other.extractEmbedded == extractEmbedded && + other.fullsize == fullsize && other.preview == preview && other.thumbnail == thumbnail; @@ -39,16 +43,18 @@ class SystemConfigImageDto { // ignore: unnecessary_parenthesis (colorspace.hashCode) + (extractEmbedded.hashCode) + + (fullsize.hashCode) + (preview.hashCode) + (thumbnail.hashCode); @override - String toString() => 'SystemConfigImageDto[colorspace=$colorspace, extractEmbedded=$extractEmbedded, preview=$preview, thumbnail=$thumbnail]'; + String toString() => 'SystemConfigImageDto[colorspace=$colorspace, extractEmbedded=$extractEmbedded, fullsize=$fullsize, preview=$preview, thumbnail=$thumbnail]'; Map toJson() { final json = {}; json[r'colorspace'] = this.colorspace; json[r'extractEmbedded'] = this.extractEmbedded; + json[r'fullsize'] = this.fullsize; json[r'preview'] = this.preview; json[r'thumbnail'] = this.thumbnail; return json; @@ -65,6 +71,7 @@ class SystemConfigImageDto { return SystemConfigImageDto( colorspace: Colorspace.fromJson(json[r'colorspace'])!, extractEmbedded: mapValueOfType(json, r'extractEmbedded')!, + fullsize: SystemConfigGeneratedFullsizeImageDto.fromJson(json[r'fullsize'])!, preview: SystemConfigGeneratedImageDto.fromJson(json[r'preview'])!, thumbnail: SystemConfigGeneratedImageDto.fromJson(json[r'thumbnail'])!, ); @@ -116,6 +123,7 @@ class SystemConfigImageDto { static const requiredKeys = { 'colorspace', 'extractEmbedded', + 'fullsize', 'preview', 'thumbnail', }; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b948ef038..d4da5ba0c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8660,6 +8660,7 @@ }, "AssetMediaSize": { "enum": [ + "fullsize", "preview", "thumbnail" ], @@ -10442,6 +10443,7 @@ "PathType": { "enum": [ "original", + "fullsize", "preview", "thumbnail", "encoded_video", @@ -12615,6 +12617,31 @@ ], "type": "object" }, + "SystemConfigGeneratedFullsizeImageDto": { + "properties": { + "enabled": { + "type": "boolean" + }, + "format": { + "allOf": [ + { + "$ref": "#/components/schemas/ImageFormat" + } + ] + }, + "quality": { + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "enabled", + "format", + "quality" + ], + "type": "object" + }, "SystemConfigGeneratedImageDto": { "properties": { "format": { @@ -12653,6 +12680,9 @@ "extractEmbedded": { "type": "boolean" }, + "fullsize": { + "$ref": "#/components/schemas/SystemConfigGeneratedFullsizeImageDto" + }, "preview": { "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" }, @@ -12663,6 +12693,7 @@ "required": [ "colorspace", "extractEmbedded", + "fullsize", "preview", "thumbnail" ], diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 26929ba4e..80e718201 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1179,6 +1179,11 @@ export type SystemConfigFFmpegDto = { transcode: TranscodePolicy; twoPass: boolean; }; +export type SystemConfigGeneratedFullsizeImageDto = { + enabled: boolean; + format: ImageFormat; + quality: number; +}; export type SystemConfigGeneratedImageDto = { format: ImageFormat; quality: number; @@ -1187,6 +1192,7 @@ export type SystemConfigGeneratedImageDto = { export type SystemConfigImageDto = { colorspace: Colorspace; extractEmbedded: boolean; + fullsize: SystemConfigGeneratedFullsizeImageDto; preview: SystemConfigGeneratedImageDto; thumbnail: SystemConfigGeneratedImageDto; }; @@ -3573,6 +3579,7 @@ export enum AssetJobName { TranscodeVideo = "transcode-video" } export enum AssetMediaSize { + Fullsize = "fullsize", Preview = "preview", Thumbnail = "thumbnail" } @@ -3622,6 +3629,7 @@ export enum PathEntityType { } export enum PathType { Original = "original", + Fullsize = "fullsize", Preview = "preview", Thumbnail = "thumbnail", EncodedVideo = "encoded_video", diff --git a/server/src/config.ts b/server/src/config.ts index e7f3d4b8b..566adbd69 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -12,7 +12,7 @@ import { VideoCodec, VideoContainer, } from 'src/enum'; -import { ConcurrentQueueName, ImageOptions } from 'src/types'; +import { ConcurrentQueueName, FullsizeImageOptions, ImageOptions } from 'src/types'; export interface SystemConfig { backup: { @@ -112,6 +112,7 @@ export interface SystemConfig { preview: ImageOptions; colorspace: Colorspace; extractEmbedded: boolean; + fullsize: FullsizeImageOptions; }; newVersionCheck: { enabled: boolean; @@ -281,6 +282,11 @@ export const defaults = Object.freeze({ }, colorspace: Colorspace.P3, extractEmbedded: false, + fullsize: { + enabled: false, + format: ImageFormat.JPEG, + quality: 80, + }, }, newVersionCheck: { enabled: true, diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index 3d2845690..b2c939758 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -10,12 +10,13 @@ import { Post, Put, Query, + Req, Res, UploadedFiles, UseInterceptors, } from '@nestjs/common'; import { ApiBody, ApiConsumes, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { NextFunction, Response } from 'express'; +import { NextFunction, Request, Response } from 'express'; import { EndpointLifecycle } from 'src/decorators'; import { AssetBulkUploadCheckResponseDto, @@ -28,6 +29,7 @@ import { AssetMediaCreateDto, AssetMediaOptionsDto, AssetMediaReplaceDto, + AssetMediaSize, CheckExistingAssetsDto, UploadFieldName, } from 'src/dtos/asset-media.dto'; @@ -39,7 +41,7 @@ import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.inte import { LoggingRepository } from 'src/repositories/logging.repository'; import { AssetMediaService } from 'src/services/asset-media.service'; import { UploadFiles } from 'src/types'; -import { sendFile } from 'src/utils/file'; +import { ImmichFileResponse, sendFile } from 'src/utils/file'; import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; @ApiTags('Assets') @@ -123,10 +125,34 @@ export class AssetMediaController { @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Query() dto: AssetMediaOptionsDto, + @Req() req: Request, @Res() res: Response, @Next() next: NextFunction, ) { - await sendFile(res, next, () => this.service.viewThumbnail(auth, id, dto), this.logger); + const viewThumbnailRes = await this.service.viewThumbnail(auth, id, dto); + + if (viewThumbnailRes instanceof ImmichFileResponse) { + await sendFile(res, next, () => Promise.resolve(viewThumbnailRes), this.logger); + } else { + // viewThumbnailRes is a AssetMediaRedirectResponse + // which redirects to the original asset or a specific size to make better use of caching + const { targetSize } = viewThumbnailRes; + const [reqPath, reqSearch] = req.url.split('?'); + let redirPath: string; + const redirSearchParams = new URLSearchParams(reqSearch); + if (targetSize === 'original') { + // relative path to this.downloadAsset + redirPath = 'original'; + redirSearchParams.delete('size'); + } else if (Object.values(AssetMediaSize).includes(targetSize)) { + redirPath = reqPath; + redirSearchParams.set('size', targetSize); + } else { + throw new Error('Invalid targetSize: ' + targetSize); + } + const finalRedirPath = redirPath + '?' + redirSearchParams.toString(); + return res.redirect(finalRedirPath); + } } @Get(':id/video/playback') diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 3160331dd..2ce7348f8 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -26,7 +26,7 @@ export interface MoveRequest { }; } -export type GeneratedImageType = AssetPathType.PREVIEW | AssetPathType.THUMBNAIL; +export type GeneratedImageType = AssetPathType.PREVIEW | AssetPathType.THUMBNAIL | AssetPathType.FULLSIZE; export type GeneratedAssetType = GeneratedImageType | AssetPathType.ENCODED_VIDEO; let instance: StorageCore | null; @@ -277,6 +277,9 @@ export class StorageCore { case AssetPathType.ORIGINAL: { return this.assetRepository.update({ id, originalPath: newPath }); } + case AssetPathType.FULLSIZE: { + return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FULLSIZE, path: newPath }); + } case AssetPathType.PREVIEW: { return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.PREVIEW, path: newPath }); } diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index c62857da6..883713859 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -4,6 +4,11 @@ import { ArrayNotEmpty, IsArray, IsEnum, IsNotEmpty, IsString, ValidateNested } import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; export enum AssetMediaSize { + /** + * An full-sized image extracted/converted from non-web-friendly formats like RAW/HIF. + * or otherwise the original image itself. + */ + FULLSIZE = 'fullsize', PREVIEW = 'preview', THUMBNAIL = 'thumbnail', } diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 6b51c015b..eaef40a5e 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -531,6 +531,24 @@ class SystemConfigGeneratedImageDto { size!: number; } +class SystemConfigGeneratedFullsizeImageDto { + @IsBoolean() + @Type(() => Boolean) + @ApiProperty({ type: 'boolean' }) + enabled!: boolean; + + @IsEnum(ImageFormat) + @ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat }) + format!: ImageFormat; + + @IsInt() + @Min(1) + @Max(100) + @Type(() => Number) + @ApiProperty({ type: 'integer' }) + quality!: number; +} + export class SystemConfigImageDto { @Type(() => SystemConfigGeneratedImageDto) @ValidateNested() @@ -542,6 +560,11 @@ export class SystemConfigImageDto { @IsObject() preview!: SystemConfigGeneratedImageDto; + @Type(() => SystemConfigGeneratedFullsizeImageDto) + @ValidateNested() + @IsObject() + fullsize!: SystemConfigGeneratedFullsizeImageDto; + @IsEnum(Colorspace) @ApiProperty({ enumName: 'Colorspace', enum: Colorspace }) colorspace!: Colorspace; diff --git a/server/src/enum.ts b/server/src/enum.ts index 6ebd1906f..e5c6039be 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -33,6 +33,10 @@ export enum AssetType { } export enum AssetFileType { + /** + * An full/large-size image extracted/converted from RAW photos + */ + FULLSIZE = 'fullsize', PREVIEW = 'preview', THUMBNAIL = 'thumbnail', } @@ -242,6 +246,7 @@ export enum ManualJobName { export enum AssetPathType { ORIGINAL = 'original', + FULLSIZE = 'fullsize', PREVIEW = 'preview', THUMBNAIL = 'thumbnail', ENCODED_VIDEO = 'encoded_video', diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index e2e1bd9a6..77154bbd1 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Insertable, Kysely, UpdateResult, Updateable, sql } from 'kysely'; +import { Insertable, Kysely, Selectable, UpdateResult, Updateable, sql } from 'kysely'; import { isEmpty, isUndefined, omitBy } from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db'; @@ -1036,6 +1036,17 @@ export class AssetRepository { .execute(); } + async deleteFiles(files: Pick, 'id'>[]): Promise { + if (files.length === 0) { + return; + } + + await this.db + .deleteFrom('asset_files') + .where('id', '=', anyUuid(files.map((file) => file.id))) + .execute(); + } + @GenerateSql({ params: [{ libraryId: DummyValue.UUID, importPaths: [DummyValue.STRING], exclusionPatterns: [DummyValue.STRING] }], }) diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index eb43b4335..d9cac0b01 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -1,11 +1,12 @@ import { Injectable } from '@nestjs/common'; -import { exiftool } from 'exiftool-vendored'; +import { ExifDateTime, exiftool, WriteTags } from 'exiftool-vendored'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; import { Duration } from 'luxon'; import fs from 'node:fs/promises'; import { Writable } from 'node:stream'; import sharp from 'sharp'; import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants'; +import { ExifEntity } from 'src/entities/exif.entity'; import { Colorspace, LogLevel } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { @@ -43,9 +44,14 @@ export class MediaRepository { async extract(input: string, output: string): Promise { 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); @@ -57,10 +63,47 @@ export class MediaRepository { } } } - return true; } + async writeExif(tags: Partial, output: string): Promise { + try { + const tagsToWrite: WriteTags = { + ExifImageWidth: tags.exifImageWidth, + ExifImageHeight: tags.exifImageHeight, + DateTimeOriginal: tags.dateTimeOriginal && ExifDateTime.fromMillis(tags.dateTimeOriginal.getTime()), + ModifyDate: tags.modifyDate && ExifDateTime.fromMillis(tags.modifyDate.getTime()), + TimeZone: tags.timeZone, + GPSLatitude: tags.latitude, + GPSLongitude: tags.longitude, + ProjectionType: tags.projectionType, + City: tags.city, + Country: tags.country, + Make: tags.make, + Model: tags.model, + LensModel: tags.lensModel, + Fnumber: tags.fNumber?.toFixed(1), + FocalLength: tags.focalLength?.toFixed(1), + ISO: tags.iso, + ExposureTime: tags.exposureTime, + ProfileDescription: tags.profileDescription, + ColorSpace: tags.colorspace, + Rating: tags.rating, + // specially convert Orientation to numeric Orientation# for exiftool + 'Orientation#': tags.orientation ? Number(tags.orientation) : undefined, + }; + + await exiftool.write(output, tagsToWrite, { + ignoreMinorErrors: true, + writeArgs: ['-overwrite_original'], + }); + return true; + } catch (error: any) { + this.logger.warn(`Could not write exif data to image: ${error.message}`); + return false; + } + } + decodeImage(input: string, options: DecodeToBufferOptions) { return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true }); } @@ -101,7 +144,10 @@ export class MediaRepository { pipeline = pipeline.extract(options.crop); } - return pipeline.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }); + if (options.size !== undefined) { + pipeline = pipeline.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }); + } + return pipeline; } async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise { diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 09ebd9db7..747d7e451 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -37,6 +37,10 @@ interface UploadRequest { file: UploadFile; } +export interface AssetMediaRedirectResponse { + targetSize: AssetMediaSize | 'original'; +} + @Injectable() export class AssetMediaService extends BaseService { async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise { @@ -194,16 +198,31 @@ export class AssetMediaService extends BaseService { }); } - async viewThumbnail(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise { + async viewThumbnail( + auth: AuthDto, + id: string, + dto: AssetMediaOptionsDto, + ): Promise { await this.requireAccess({ auth, permission: Permission.ASSET_VIEW, ids: [id] }); const asset = await this.findOrFail(id); const size = dto.size ?? AssetMediaSize.THUMBNAIL; - const { thumbnailFile, previewFile } = getAssetFiles(asset.files); + const { thumbnailFile, previewFile, fullsizeFile } = getAssetFiles(asset.files); let filepath = previewFile?.path; if (size === AssetMediaSize.THUMBNAIL && thumbnailFile) { filepath = thumbnailFile.path; + } else if (size === AssetMediaSize.FULLSIZE) { + if (mimeTypes.isWebSupportedImage(asset.originalPath)) { + // use original file for web supported images + return { targetSize: 'original' }; + } + if (!fullsizeFile) { + // downgrade to preview if fullsize is not available. + // e.g. disabled or not yet (re)generated + return { targetSize: AssetMediaSize.PREVIEW }; + } + filepath = fullsizeFile.path; } if (!filepath) { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 9b8cc820c..354cf25b9 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,5 +1,6 @@ import { OutputInfo } from 'sharp'; import { SystemConfig } from 'src/config'; +import { AssetMediaSize } from 'src/dtos/asset-media.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { @@ -233,18 +234,26 @@ describe(MediaService.name, () => { }); await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(mocks.move.create).toHaveBeenCalledTimes(2); + expect(mocks.move.create).toHaveBeenCalledTimes(3); }); }); describe('handleGenerateThumbnails', () => { let rawBuffer: Buffer; + let fullsizeBuffer: Buffer; let rawInfo: RawImageInfo; beforeEach(() => { + fullsizeBuffer = Buffer.from('embedded image data'); rawBuffer = Buffer.from('image data'); rawInfo = { width: 100, height: 100, channels: 3 }; - mocks.media.decodeImage.mockResolvedValue({ data: rawBuffer, info: rawInfo as OutputInfo }); + mocks.media.decodeImage.mockImplementation((path) => + Promise.resolve( + path.includes(AssetMediaSize.FULLSIZE) + ? { data: fullsizeBuffer, info: rawInfo as OutputInfo } + : { data: rawBuffer, info: rawInfo as OutputInfo }, + ), + ); }); it('should skip thumbnail generation if asset not found', async () => { @@ -591,15 +600,13 @@ describe(MediaService.name, () => { await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - const extractedPath = mocks.media.extract.mock.calls.at(-1)?.[1].toString(); + const convertedPath = mocks.media.extract.mock.lastCall?.[1].toString(); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(convertedPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); - expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(mocks.storage.unlink).toHaveBeenCalledWith(extractedPath); }); it('should resize original image if embedded image is too small', async () => { @@ -610,15 +617,14 @@ describe(MediaService.name, () => { await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + 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, size: 1440, }); - const extractedPath = mocks.media.extract.mock.calls.at(-1)?.[1].toString(); - expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(mocks.storage.unlink).toHaveBeenCalledWith(extractedPath); }); it('should resize original image if embedded image not found', async () => { @@ -686,6 +692,159 @@ describe(MediaService.name, () => { 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); + mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.asset.getById.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, { + colorspace: Colorspace.P3, + processInvalidImages: false, + }); + + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + 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.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.asset.getById.mockResolvedValue(assetStub.imageDng); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + }); + + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg', + ); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, + quality: 80, + size: 1440, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + ); + }); + + 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.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. + mocks.asset.getById.mockResolvedValue(assetStub.imageHif); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageHif.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + }); + + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg', + ); + }); + + 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.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.asset.getById.mockResolvedValue(assetStub.image); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.SRGB, + processInvalidImages: false, + size: 1440, + }); + + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg', + ); + }); + + it('should respect encoding options when generating full-size preview', async () => { + mocks.systemMetadata.get.mockResolvedValue({ + image: { fullsize: { enabled: true, format: ImageFormat.WEBP, quality: 90 } }, + }); + mocks.media.extract.mockResolvedValue(true); + 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.asset.getById.mockResolvedValue(assetStub.imageHif); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); + expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageHif.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + }); + + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.WEBP, + quality: 90, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-fullsize.webp', + ); + }); }); describe('handleQueueVideoConversion', () => { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 54540dff6..4e459db69 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { dirname } from 'node:path'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; @@ -11,6 +10,7 @@ import { AssetType, AudioCodec, Colorspace, + ImageFormat, JobName, JobStatus, LogLevel, @@ -24,7 +24,16 @@ import { } from 'src/enum'; import { UpsertFileOptions, WithoutProperty } from 'src/repositories/asset.repository'; import { BaseService } from 'src/services/base.service'; -import { AudioStreamInfo, JobItem, JobOf, VideoFormat, VideoInterfaces, VideoStreamInfo } from 'src/types'; +import { + AudioStreamInfo, + DecodeToBufferOptions, + GenerateThumbnailOptions, + JobItem, + JobOf, + VideoFormat, + VideoInterfaces, + VideoStreamInfo, +} from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; @@ -131,6 +140,7 @@ export class MediaService extends BaseService { return JobStatus.FAILED; } + await this.storageCore.moveAssetImage(asset, AssetPathType.FULLSIZE, ImageFormat.JPEG); await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.preview.format); await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); await this.storageCore.moveAssetVideo(asset); @@ -151,7 +161,12 @@ export class MediaService extends BaseService { return JobStatus.SKIPPED; } - let generated: { previewPath: string; thumbnailPath: string; thumbhash: Buffer }; + let generated: { + previewPath: string; + thumbnailPath: string; + fullsizePath?: string; + thumbhash: Buffer; + }; if (asset.type === AssetType.VIDEO || asset.originalFileName.toLowerCase().endsWith('.gif')) { generated = await this.generateVideoThumbnails(asset); } else if (asset.type === AssetType.IMAGE) { @@ -161,7 +176,7 @@ export class MediaService extends BaseService { return JobStatus.SKIPPED; } - const { previewFile, thumbnailFile } = getAssetFiles(asset.files); + const { previewFile, thumbnailFile, fullsizeFile } = getAssetFiles(asset.files); const toUpsert: UpsertFileOptions[] = []; if (previewFile?.path !== generated.previewPath) { toUpsert.push({ assetId: asset.id, path: generated.previewPath, type: AssetFileType.PREVIEW }); @@ -171,11 +186,15 @@ export class MediaService extends BaseService { toUpsert.push({ assetId: asset.id, path: generated.thumbnailPath, type: AssetFileType.THUMBNAIL }); } + if (generated.fullsizePath && fullsizeFile?.path !== generated.fullsizePath) { + toUpsert.push({ assetId: asset.id, path: generated.fullsizePath, type: AssetFileType.FULLSIZE }); + } + if (toUpsert.length > 0) { await this.assetRepository.upsertFiles(toUpsert); } - const pathsToDelete = []; + const pathsToDelete: string[] = []; if (previewFile && previewFile.path !== generated.previewPath) { this.logger.debug(`Deleting old preview for asset ${asset.id}`); pathsToDelete.push(previewFile.path); @@ -186,6 +205,15 @@ export class MediaService extends BaseService { pathsToDelete.push(thumbnailFile.path); } + if (fullsizeFile && fullsizeFile.path !== generated.fullsizePath) { + this.logger.debug(`Deleting old fullsize preview image for asset ${asset.id}`); + pathsToDelete.push(fullsizeFile.path); + if (!generated.fullsizePath) { + // did not generate a new fullsize image, delete the existing record + await this.assetRepository.deleteFiles([fullsizeFile]); + } + } + if (pathsToDelete.length > 0) { await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path))); } @@ -205,33 +233,71 @@ export class MediaService extends BaseService { const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); this.storageCore.ensureFolders(previewPath); - const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath); - const extractedPath = StorageCore.getTempPathInDir(dirname(previewPath)); - const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); + const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true'; + const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; - try { - const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size)); - const inputPath = useExtracted ? extractedPath : asset.originalPath; - const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; - const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true'; + // prevents this extra "enabled" from leaking into fullsizeOptions later + const { enabled: imageFullsizeEnabled, ...imageFullsizeConfig } = image.fullsize; - const orientation = useExtracted && asset.exifInfo?.orientation ? Number(asset.exifInfo.orientation) : undefined; - const decodeOptions = { colorspace, processInvalidImages, size: image.preview.size, orientation }; - const { data, info } = await this.mediaRepository.decodeImage(inputPath, decodeOptions); + const shouldConvertFullsize = imageFullsizeEnabled && !mimeTypes.isWebSupportedImage(asset.originalFileName); + const shouldExtractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName); + const decodeOptions: DecodeToBufferOptions = { colorspace, processInvalidImages, size: image.preview.size }; - const options = { colorspace, processInvalidImages, raw: info }; - const outputs = await Promise.all([ - this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...options }, thumbnailPath), - this.mediaRepository.generateThumbnail(data, { ...image.preview, ...options }, previewPath), - this.mediaRepository.generateThumbhash(data, options), - ]); + let useExtracted = false; + let decodeInputPath: string = asset.originalPath; + // Converted or extracted image from non-web-supported formats (e.g. RAW) + let fullsizePath: string | undefined; - return { previewPath, thumbnailPath, thumbhash: outputs[2] }; - } finally { - if (didExtract) { - await this.storageRepository.unlink(extractedPath); + 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 }; + 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, + }; + promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath)); + } + const outputs = await Promise.all(promises); + + return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer }; } private async generateVideoThumbnails(asset: AssetEntity) { diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 8a06a883c..936acf27a 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -146,6 +146,7 @@ const updatedConfig = Object.freeze({ format: ImageFormat.JPEG, quality: 80, }, + fullsize: { enabled: false, format: ImageFormat.JPEG, quality: 80 }, colorspace: Colorspace.P3, extractEmbedded: false, }, diff --git a/server/src/types.ts b/server/src/types.ts index 6a3860830..623b0fece 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -58,6 +58,12 @@ export interface CropOptions { height: number; } +export interface FullsizeImageOptions { + format: ImageFormat; + quality: number; + enabled: boolean; +} + export interface ImageOptions { format: ImageFormat; quality: number; @@ -78,11 +84,11 @@ interface DecodeImageOptions { } export interface DecodeToBufferOptions extends DecodeImageOptions { - size: number; + size?: number; orientation?: ExifOrientation; } -export type GenerateThumbnailOptions = ImageOptions & DecodeImageOptions; +export type GenerateThumbnailOptions = Pick & DecodeToBufferOptions; export type GenerateThumbnailFromBufferOptions = GenerateThumbnailOptions & { raw: RawImageInfo }; diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index de64720a8..d5b100bf0 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -18,6 +18,7 @@ const getFileByType = (files: AssetFileEntity[] | undefined, type: AssetFileType }; export const getAssetFiles = (files?: AssetFileEntity[]) => ({ + fullsizeFile: getFileByType(files, AssetFileType.FULLSIZE), previewFile: getFileByType(files, AssetFileType.PREVIEW), thumbnailFile: getFileByType(files, AssetFileType.THUMBNAIL), }); diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index 37dfe8153..7beeb91b6 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -55,6 +55,20 @@ const image: Record = { '.webp': ['image/webp'], }; +/** + * 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 = Object.fromEntries( Object.entries(image).filter(([key]) => profileExtensions.has(key)), @@ -100,6 +114,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)), isProfile: (filename: string) => isType(filename, profile), isSidecar: (filename: string) => isType(filename, sidecar), isVideo: (filename: string) => isType(filename, video), diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index c0902dddb..ce85da670 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -789,7 +789,47 @@ export const assetStub = { livePhotoVideoId: null, tags: [], sharedLinks: [], - originalFileName: 'asset-id.jpg', + originalFileName: 'asset-id.dng', + faces: [], + deletedAt: null, + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + profileDescription: 'Adobe RGB', + bitsPerSample: 14, + } as ExifEntity, + duplicateId: null, + isOffline: false, + }), + + imageHif: Object.freeze({ + id: 'asset-id', + status: AssetStatus.ACTIVE, + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.hif', + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + files, + thumbhash: Buffer.from('blablabla', 'base64'), + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + localDateTime: new Date('2023-02-23T05:06:29.716Z'), + isFavorite: true, + isArchived: false, + duration: null, + isVisible: true, + isExternal: false, + livePhotoVideo: null, + livePhotoVideoId: null, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.hif', faces: [], deletedAt: null, sidecarPath: null, diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 1114a70f9..a17ca03e8 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -41,6 +41,7 @@ export const newAssetRepositoryMock = (): Mocked> => { return { generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()), + 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), diff --git a/web/src/lib/components/admin-page/settings/image/image-settings.svelte b/web/src/lib/components/admin-page/settings/image/image-settings.svelte index 2f2bcbca6..9a66ad9c9 100644 --- a/web/src/lib/components/admin-page/settings/image/image-settings.svelte +++ b/web/src/lib/components/admin-page/settings/image/image-settings.svelte @@ -40,7 +40,7 @@
-
+
- (config.image.colorspace = isChecked ? Colorspace.P3 : Colorspace.Srgb)} - isEdited={config.image.colorspace !== savedConfig.image.colorspace} - {disabled} - /> + + (config.image.fullsize.enabled = isChecked)} + isEdited={config.image.fullsize.enabled !== savedConfig.image.fullsize.enabled} + {disabled} + /> - (config.image.extractEmbedded = !config.image.extractEmbedded)} - isEdited={config.image.extractEmbedded !== savedConfig.image.extractEmbedded} - {disabled} - /> +
+ + + + +
+ +
+ (config.image.colorspace = isChecked ? Colorspace.P3 : Colorspace.Srgb)} + isEdited={config.image.colorspace !== savedConfig.image.colorspace} + {disabled} + /> +
+ +
+ (config.image.extractEmbedded = !config.image.extractEmbedded)} + isEdited={config.image.extractEmbedded !== savedConfig.image.extractEmbedded} + {disabled} + /> +
-
+
onReset({ ...options, configKeys: ['image'] })} onSave={() => onSave({ image: config.image })} diff --git a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts index d90fb89c2..8690aae0c 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts +++ b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts @@ -48,7 +48,7 @@ describe('PhotoViewer component', () => { expect(getAssetThumbnailUrlSpy).toBeCalledWith({ id: asset.id, size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, + cacheKey: asset.checksum, }); expect(getAssetOriginalUrlSpy).not.toBeCalled(); }); @@ -57,8 +57,11 @@ describe('PhotoViewer component', () => { const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' }); render(PhotoViewer, { asset }); - expect(getAssetThumbnailUrlSpy).not.toBeCalled(); - expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); + expect(getAssetThumbnailUrlSpy).toBeCalledWith({ + id: asset.id, + cacheKey: asset.checksum, + size: AssetMediaSize.Fullsize, + }); }); it('loads original for shared link when download permission is true and showMetadata permission is true', () => { @@ -66,8 +69,13 @@ describe('PhotoViewer component', () => { const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] }); render(PhotoViewer, { asset, sharedLink }); - expect(getAssetThumbnailUrlSpy).not.toBeCalled(); - expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); + expect(getAssetThumbnailUrlSpy).toBeCalledWith({ + id: asset.id, + size: AssetMediaSize.Fullsize, + cacheKey: asset.checksum, + }); + // expect(getAssetThumbnailUrlSpy).not.toBeCalled(); + // expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); }); it('not loads original image when shared link download permission is false', () => { @@ -78,7 +86,7 @@ describe('PhotoViewer component', () => { expect(getAssetThumbnailUrlSpy).toBeCalledWith({ id: asset.id, size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, + cacheKey: asset.checksum, }); expect(getAssetOriginalUrlSpy).not.toBeCalled(); @@ -92,7 +100,7 @@ describe('PhotoViewer component', () => { expect(getAssetThumbnailUrlSpy).toBeCalledWith({ id: asset.id, size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, + cacheKey: asset.checksum, }); expect(getAssetOriginalUrlSpy).not.toBeCalled(); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 754693897..1d7c49f10 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -6,8 +6,8 @@ import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; - import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; - import { isWebCompatibleImage, canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; + import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; + import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; import { getBoundingBox } from '$lib/utils/people-utils'; import { getAltText } from '$lib/utils/thumbnail-util'; import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; @@ -50,6 +50,7 @@ let assetFileUrl: string = $state(''); let imageLoaded: boolean = $state(false); + let originalImageLoaded: boolean = $state(false); let imageError: boolean = $state(false); let loader = $state(); @@ -67,23 +68,22 @@ $boundingBoxesArray = []; }); - const preload = (useOriginal: boolean, preloadAssets?: AssetResponseDto[]) => { + const preload = (targetSize: AssetMediaSize, preloadAssets?: AssetResponseDto[]) => { for (const preloadAsset of preloadAssets || []) { if (preloadAsset.type === AssetTypeEnum.Image) { let img = new Image(); - img.src = getAssetUrl(preloadAsset.id, useOriginal, preloadAsset.thumbhash); + img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash); } } }; - const getAssetUrl = (id: string, useOriginal: boolean, cacheKey: string | null) => { + const getAssetUrl = (id: string, targetSize: AssetMediaSize, cacheKey: string | null) => { + let finalAssetMediaSize = targetSize; if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) { - return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey }); + finalAssetMediaSize = AssetMediaSize.Preview; } - return useOriginal - ? getAssetOriginalUrl({ id, cacheKey }) - : getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey }); + return getAssetThumbnailUrl({ id, size: finalAssetMediaSize, cacheKey }); }; copyImage = async () => { @@ -133,14 +133,30 @@ } }; + // when true, will force loading of the original image + let forceUseOriginal: boolean = $derived(asset.originalMimeType === 'image/gif' || $photoZoomState.currentZoom > 1); + + const targetImageSize = $derived( + $alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded + ? AssetMediaSize.Fullsize + : AssetMediaSize.Preview, + ); + + const onload = () => { + imageLoaded = true; + assetFileUrl = imageLoaderUrl; + originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize; + }; + + const onerror = () => { + imageError = imageLoaded = true; + }; + + $effect(() => { + preload(targetImageSize, preloadAssets); + }); + onMount(() => { - const onload = () => { - imageLoaded = true; - assetFileUrl = imageLoaderUrl; - }; - const onerror = () => { - imageError = imageLoaded = true; - }; if (loader?.complete) { onload(); } @@ -151,21 +167,8 @@ loader?.removeEventListener('error', onerror); }; }); - let isWebCompatible = $derived(isWebCompatibleImage(asset)); - let useOriginalByDefault = $derived(isWebCompatible && $alwaysLoadOriginalFile); - // when true, will force loading of the original image - let forceUseOriginal: boolean = $derived( - asset.originalMimeType === 'image/gif' || ($photoZoomState.currentZoom > 1 && isWebCompatible), - ); - - let useOriginalImage = $derived(useOriginalByDefault || forceUseOriginal); - - $effect(() => { - preload(useOriginalImage, preloadAssets); - }); - - let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.thumbhash)); + let imageLoaderUrl = $derived(getAssetUrl(asset.id, targetImageSize, asset.checksum)); let containerWidth = $state(0); let containerHeight = $state(0); @@ -188,13 +191,7 @@ bind:clientWidth={containerWidth} bind:clientHeight={containerHeight} > - {$getAltText(asset)} ((imageLoaded = true), (assetFileUrl = imageLoaderUrl))} - onerror={() => (imageError = imageLoaded = true)} - /> + {$getAltText(asset)} {#if !imageLoaded}