diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index a0c429a82e224..269771001a8b2 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -1235,6 +1235,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/i18n/en.json b/i18n/en.json index 5b14cd18033e0..4c4fc231f6741 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 preview", + "image_fullsize_enabled_description": "Generate and use full-size image preview for non-web-friendly images (like RAW, HIF) when zoomed in. When \"Prefer embedded preview\" is enabled, embedded previews in RAW photos are used directly without conversion. Original web-friendly images (like JPEG) are always used regardeless of this switch.", + "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 iamge with stripped metadata, used when zoomed in in the image viewer", "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 b336b1bfb6f40..a631b5b786653 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -418,6 +418,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 73eb02d89ed7a..7b0f42d394a43 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -231,6 +231,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 a6f8d551da81c..34950f3eda4c1 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -516,6 +516,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 2a950db411820..aa7e2a6f5c27a 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 bfb16c66670bb..55453ed1e8e51 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 0000000000000..fbeb704b2782d --- /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 5309f7745c44d..783eaa7d46060 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 7c8aba3b5e985..28f52f5925f43 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8363,6 +8363,7 @@ }, "AssetMediaSize": { "enum": [ + "fullsize", "preview", "thumbnail" ], @@ -10089,6 +10090,7 @@ "PathType": { "enum": [ "original", + "fullsize", "preview", "thumbnail", "encoded_video", @@ -11763,6 +11765,27 @@ ], "type": "object" }, + "SystemConfigGeneratedFullsizeImageDto": { + "properties": { + "enabled": { + "type": "boolean" + }, + "format": { + "$ref": "#/components/schemas/ImageFormat" + }, + "quality": { + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "enabled", + "format", + "quality" + ], + "type": "object" + }, "SystemConfigGeneratedImageDto": { "properties": { "format": { @@ -11793,6 +11816,9 @@ "extractEmbedded": { "type": "boolean" }, + "fullsize": { + "$ref": "#/components/schemas/SystemConfigGeneratedFullsizeImageDto" + }, "preview": { "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" }, @@ -11803,6 +11829,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 c31e71d05e961..99a8aab0ff86b 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1123,6 +1123,11 @@ export type SystemConfigFFmpegDto = { transcode: TranscodePolicy; twoPass: boolean; }; +export type SystemConfigGeneratedFullsizeImageDto = { + enabled: boolean; + format: ImageFormat; + quality: number; +}; export type SystemConfigGeneratedImageDto = { format: ImageFormat; quality: number; @@ -1131,6 +1136,7 @@ export type SystemConfigGeneratedImageDto = { export type SystemConfigImageDto = { colorspace: Colorspace; extractEmbedded: boolean; + fullsize: SystemConfigGeneratedFullsizeImageDto; preview: SystemConfigGeneratedImageDto; thumbnail: SystemConfigGeneratedImageDto; }; @@ -3464,6 +3470,7 @@ export enum AssetJobName { TranscodeVideo = "transcode-video" } export enum AssetMediaSize { + Fullsize = "fullsize", Preview = "preview", Thumbnail = "thumbnail" } @@ -3514,6 +3521,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 26589742003e7..33a863b846388 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -12,7 +12,7 @@ import { VideoContainer, } from 'src/enum'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; -import { ImageOptions } from 'src/interfaces/media.interface'; +import { FullsizeImageOptions, ImageOptions } from 'src/interfaces/media.interface'; 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/cores/storage.core.ts b/server/src/cores/storage.core.ts index c49175172d66e..15b35695207fc 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 c62857da65042..8837138599250 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 350918254542a..e96046215bd63 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -530,6 +530,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() @@ -541,6 +559,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 3440d45cee6d2..536decc6045d9 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', } @@ -237,6 +241,7 @@ export enum ManualJobName { export enum AssetPathType { ORIGINAL = 'original', + FULLSIZE = 'fullsize', PREVIEW = 'preview', THUMBNAIL = 'thumbnail', ENCODED_VIDEO = 'encoded_video', diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index b90dfb483c261..3575902e15f41 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -1,4 +1,5 @@ import { Writable } from 'node:stream'; +import { ExifEntity } from 'src/entities/exif.entity'; import { ExifOrientation, ImageFormat, TranscodeTarget, VideoCodec } from 'src/enum'; export const IMediaRepository = 'IMediaRepository'; @@ -10,6 +11,12 @@ export interface CropOptions { height: number; } +export interface FullsizeImageOptions { + format: ImageFormat; + quality: number; + enabled: boolean; +} + export interface ImageOptions { format: ImageFormat; quality: number; @@ -30,11 +37,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 }; @@ -137,7 +144,8 @@ export interface VideoInterfaces { export interface IMediaRepository { // image - extract(input: string, output: string): Promise; + extract(input: string, output: string, withExif?: boolean): Promise; + writeExif(tags: Partial, output: string): Promise; decodeImage(input: string, options: DecodeToBufferOptions): Promise; generateThumbnail(input: string, options: GenerateThumbnailOptions, outputFile: string): Promise; generateThumbnail(input: Buffer, options: GenerateThumbnailFromBufferOptions, outputFile: string): Promise; diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 8dcbf208c6ae8..8a2b04a0b677a 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -1,19 +1,20 @@ import { Inject, 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 { ILoggerRepository } from 'src/interfaces/logger.interface'; import { DecodeToBufferOptions, GenerateThumbhashOptions, GenerateThumbnailOptions, - IMediaRepository, ImageDimensions, + IMediaRepository, ProbeOptions, TranscodeCommand, VideoInfo, @@ -44,6 +45,11 @@ export class MediaRepository implements IMediaRepository { async extract(input: string, output: string): Promise { try { + // remove existing output file if it exists + // as exiftool-vendord 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); + 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); @@ -54,10 +60,47 @@ export class MediaRepository implements IMediaRepository { return false; } } - return true; } + async writeExif(tags: ExifEntity, 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 }); } @@ -98,7 +141,10 @@ export class MediaRepository implements IMediaRepository { 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 e96d1fd0a6f4b..788356b6a21dc 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -208,10 +208,18 @@ export class AssetMediaService extends BaseService { 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) { + await this.requireAccess({ auth, permission: Permission.ASSET_DOWNLOAD, ids: [id] }); + // eslint-disable-next-line unicorn/prefer-ternary + if (mimeTypes.isWebSupportedImage(asset.originalPath)) { + filepath = asset.originalPath; + } else { + filepath = fullsizeFile?.path ?? previewFile?.path; + } } if (!filepath) { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 36a9045677460..eb3c0c74d1d82 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -269,7 +269,7 @@ describe(MediaService.name, () => { }); await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); - expect(moveMock.create).toHaveBeenCalledTimes(2); + expect(moveMock.create).toHaveBeenCalledTimes(3); }); }); @@ -627,15 +627,13 @@ describe(MediaService.name, () => { await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + const convertedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); - expect(mediaMock.decodeImage).toHaveBeenCalledWith(extractedPath, { + expect(mediaMock.decodeImage).toHaveBeenCalledWith(convertedPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, }); - expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); }); it('should resize original image if embedded image is too small', async () => { @@ -646,15 +644,18 @@ describe(MediaService.name, () => { await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + const convertedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ size: 1440 }), + convertedPath, + ); + expect(convertedPath).toMatch(/-converted\.jpeg$/); expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, - size: 1440, }); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); - expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); }); it('should resize original image if embedded image not found', async () => { @@ -667,7 +668,6 @@ describe(MediaService.name, () => { expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, - size: 1440, }); expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); }); @@ -683,7 +683,6 @@ describe(MediaService.name, () => { expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, - size: 1440, }); expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); }); @@ -701,7 +700,12 @@ describe(MediaService.name, () => { expect.objectContaining({ processInvalidImages: true }), ); - expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), + 'upload/thumbs/user-id/as/se/asset-id-converted.jpeg', + ); expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( rawBuffer, expect.objectContaining({ processInvalidImages: true }), diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 7036bd32e831c..10229d21c8dda 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 { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; @@ -10,6 +9,7 @@ import { AssetType, AudioCodec, Colorspace, + ImageFormat, LogLevel, StorageFolder, TranscodeHWAccel, @@ -27,7 +27,13 @@ import { JobStatus, QueueName, } from 'src/interfaces/job.interface'; -import { AudioStreamInfo, VideoFormat, VideoInterfaces, VideoStreamInfo } from 'src/interfaces/media.interface'; +import { + AudioStreamInfo, + DecodeToBufferOptions, + VideoFormat, + VideoInterfaces, + VideoStreamInfo, +} from 'src/interfaces/media.interface'; import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; @@ -135,6 +141,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); @@ -155,7 +162,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) { @@ -165,7 +177,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 }); @@ -175,11 +187,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); @@ -190,6 +206,11 @@ 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 (pathsToDelete.length > 0) { await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path))); } @@ -209,33 +230,73 @@ 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)); - - 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'; - - 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 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), - ]); - - return { previewPath, thumbnailPath, thumbhash: outputs[2] }; - } finally { - if (didExtract) { - await this.storageRepository.unlink(extractedPath); + const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true'; + const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; + const imageIsWebSupported = mimeTypes.isWebSupportedImage(asset.originalFileName); + const imageIsRaw = mimeTypes.isRaw(asset.originalFileName); + + const { enabled: enableFullsizeImage, ...fullsizeImageOptions } = image.fullsize; + const shouldConvertFullsize = !imageIsWebSupported && enableFullsizeImage; + const shouldExtractEmbedded = imageIsRaw && image.extractEmbedded; + 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 = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, image.preview.format); + + if (shouldConvertFullsize) { + // unset size to decode fullsize image + decodeOptions.size = undefined; + } + + 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, true); + 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 + await this.mediaRepository.writeExif( + { orientation: asset.exifInfo.orientation, colorspace: asset.exifInfo.colorspace }, + extractedPath, + ); + } } } + + const { info, data } = await this.mediaRepository.decodeImage(decodeInputPath, decodeOptions); + + const thumbnailOptions = { colorspace, processInvalidImages, raw: info }; + const outputs = await Promise.all([ + this.mediaRepository.generateThumbhash(data, thumbnailOptions), + this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailPath), + this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewPath), + fullsizePath && + !useExtracted && // did not extract a usable image from RAW + this.mediaRepository.generateThumbnail( + data, + { ...fullsizeImageOptions, ...thumbnailOptions, size: undefined }, + fullsizePath, + ), + ]); + + return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] }; } 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 2a20f329330ae..c2fdf05016655 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -153,6 +153,7 @@ const updatedConfig = Object.freeze({ }, colorspace: Colorspace.P3, extractEmbedded: false, + fullsizePreview: false, }, newVersionCheck: { enabled: true, diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index f8bed5485f8b1..b56a48c9c434c 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -25,6 +25,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/file.ts b/server/src/utils/file.ts index 869e4d78765ea..7f685ee27857e 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -46,7 +46,7 @@ export const sendFile = async ( const file = await handler(); switch (file.cacheControl) { case CacheControl.PRIVATE_WITH_CACHE: { - res.set('Cache-Control', 'private, max-age=86400, no-transform'); + res.set('Cache-Control', 'private, max-age=3600, stale-while-revalidate=86400, no-transform'); break; } diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index 165eb44a4f514..c92dd7a7e60ee 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -54,6 +54,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)), @@ -98,6 +112,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/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index a809b08162347..c95c8c2aa1d72 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -5,6 +5,7 @@ export const newMediaRepositoryMock = (): Mocked => { return { generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()), generateThumbhash: vitest.fn().mockImplementation(() => Promise.resolve()), + writeExif: vitest.fn().mockImplementation(() => Promise.resolve()), decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }), extract: vitest.fn().mockResolvedValue(false), probe: vitest.fn(), 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 2f2bcbca64276..947e1593b9c85 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 @@ -132,6 +132,44 @@ /> + + (config.image.fullsize.enabled = isChecked)} + isEdited={config.image.fullsize.enabled !== savedConfig.image.fullsize.enabled} + {disabled} + /> + + + + + + { const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' }); render(PhotoViewer, { asset }); - expect(getAssetThumbnailUrlSpy).not.toBeCalled(); - expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, checksum: asset.checksum }); + expect(getAssetThumbnailUrlSpy).toBeCalledWith({ + id: asset.id, + size: AssetMediaSize.Fullsize, + checksum: asset.checksum, + }); }); it('loads original for shared link when download permission is true and showMetadata permission is true', () => { @@ -58,8 +61,11 @@ 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, checksum: asset.checksum }); + expect(getAssetThumbnailUrlSpy).toBeCalledWith({ + id: asset.id, + size: AssetMediaSize.Fullsize, + checksum: asset.checksum, + }); }); it('not loads original image when shared link download permission is false', () => { diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index e24751b3c83fd..d1c52b6d855ac 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -7,7 +7,7 @@ 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 { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; import { isWebCompatibleImage, canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; import { getBoundingBox } from '$lib/utils/people-utils'; import { getAltText } from '$lib/utils/thumbnail-util'; @@ -66,25 +66,23 @@ $boundingBoxesArray = []; }); - const preload = (useOriginal: boolean, preloadAssets?: AssetResponseDto[]) => { + const getAssetUrl = (id: string, targetSize: AssetMediaSize, checksum: string) => { + if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) { + return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, checksum }); + } + + return getAssetThumbnailUrl({ id, size: targetSize, checksum }); + }; + + 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.checksum); + img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.checksum); } } }; - const getAssetUrl = (id: string, useOriginal: boolean, checksum: string) => { - if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) { - return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, checksum }); - } - - return useOriginal - ? getAssetOriginalUrl({ id, checksum }) - : getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, checksum }); - }; - copyImage = async () => { if (!canCopyImageToClipboard()) { return; @@ -144,21 +142,23 @@ 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); - let forceUseOriginal: boolean = $derived( - asset.originalMimeType === 'image/gif' || ($photoZoomState.currentZoom > 1 && isWebCompatible), + const targetImageSize = $derived( + useOriginalByDefault || forceUseOriginal ? AssetMediaSize.Fullsize : AssetMediaSize.Preview, ); - let useOriginalImage = $derived(useOriginalByDefault || forceUseOriginal); - $effect(() => { - preload(useOriginalImage, preloadAssets); + preload(targetImageSize, preloadAssets); }); - let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.checksum)); + let imageLoaderUrl = $derived(getAssetUrl(asset.id, targetImageSize, asset.checksum));