diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 32cbdd6df812a..591891c5150d1 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -1246,6 +1246,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 09340e98cbfb3..37e691625a5ad 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 ad48a969913f0..b390c264661f6 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", @@ -1350,4 +1355,4 @@ "yes": "Yes", "you_dont_have_any_shared_links": "You don't have any shared links", "zoom_image": "Zoom Image" -} \ No newline at end of file +} diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e0a88fcb43b4a..3c2f2173c6ec7 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 815dc7452dc34..1179217c057d0 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8406,6 +8406,7 @@ }, "AssetMediaSize": { "enum": [ + "fullsize", "preview", "thumbnail" ], @@ -10187,6 +10188,7 @@ "PathType": { "enum": [ "original", + "fullsize", "preview", "thumbnail", "encoded_video", @@ -11903,6 +11905,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": { @@ -11941,6 +11968,9 @@ "extractEmbedded": { "type": "boolean" }, + "fullsize": { + "$ref": "#/components/schemas/SystemConfigGeneratedFullsizeImageDto" + }, "preview": { "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" }, @@ -11951,6 +11981,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 3cfa15268fa1a..a62501062a5bc 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 7dd015c0fa0ba..5515d03e3b07f 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/types'; +import { 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 553f1a261f871..18e9063229ccd 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, 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'; @@ -38,7 +40,7 @@ import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; import { FileUploadInterceptor, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { AssetMediaService } from 'src/services/asset-media.service'; -import { sendFile } from 'src/utils/file'; +import { ImmichFileResponse, sendFile } from 'src/utils/file'; import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; @ApiTags('Assets') @@ -118,10 +120,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 50b07981a6f49..1347e485513df 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -25,7 +25,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; @@ -276,6 +276,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/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 5abaf9af26607..4944f7b6b6cd3 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -1,4 +1,4 @@ -import { Insertable, Updateable } from 'kysely'; +import { Insertable, Selectable, Updateable } from 'kysely'; import { AssetFiles, AssetJobStatus, Assets, Exif } from 'src/db'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum'; @@ -167,4 +167,5 @@ export interface IAssetRepository { getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise; upsertFile(options: Insertable): Promise; upsertFiles(options: Insertable[]): Promise; + deleteFiles(options: Selectable[]): Promise; } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index b39781209eade..55a7bf6d8747d 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, Updateable, sql } from 'kysely'; +import { Insertable, Kysely, Selectable, Updateable, sql } from 'kysely'; import { isEmpty, isUndefined, omitBy } from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { ASSET_FILE_CONFLICT_KEYS, EXIF_CONFLICT_KEYS, JOB_STATUS_CONFLICT_KEYS } from 'src/constants'; @@ -841,4 +841,15 @@ export class AssetRepository implements IAssetRepository { ) .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(); + } } diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 483bd3fd90363..e13cb7acc37ac 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,6 +44,11 @@ export class MediaRepository { 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); @@ -53,10 +59,47 @@ export class MediaRepository { return false; } } - 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 }); } @@ -97,7 +140,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 fab836db94b32..03443aecf4ecd 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -44,6 +44,10 @@ export interface UploadFile { size: number; } +export interface AssetMediaRedirectResponse { + targetSize: AssetMediaSize | 'original'; +} + @Injectable() export class AssetMediaService extends BaseService { async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise { @@ -201,16 +205,30 @@ 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 }; + } } if (!filepath) { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 9ce6c8edb92e9..21fd92c2bedc1 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 { @@ -246,18 +247,26 @@ 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); }); }); 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 }; - mediaMock.decodeImage.mockResolvedValue({ data: rawBuffer, info: rawInfo as OutputInfo }); + mediaMock.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 () => { @@ -604,15 +613,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.lastCall?.[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 () => { @@ -623,15 +630,15 @@ describe(MediaService.name, () => { await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + const extractedPath = mediaMock.extract.mock.lastCall?.[1].toString(); + expect(extractedPath).toMatch(/-fullsize\.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 () => { @@ -699,6 +706,128 @@ describe(MediaService.name, () => { expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); vi.unstubAllEnvs(); }); + + it('should generate full-size preview using embedded JPEG from RAW images when extractEmbedded is true', async () => { + systemMock.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: true } }); + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + const extractedPath = mediaMock.extract.mock.lastCall?.[1].toString(); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(extractedPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + }); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.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 () => { + systemMock.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } }); + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + }); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mediaMock.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(mediaMock.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 () => { + systemMock.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. + assetMock.getById.mockResolvedValue(assetStub.imageHif); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageHif.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + }); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mediaMock.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 () => { + systemMock.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + assetMock.getById.mockResolvedValue(assetStub.image); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.SRGB, + processInvalidImages: false, + size: 1440, + }); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg', + ); + }); }); describe('handleQueueVideoConversion', () => { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 5555a937f8387..401183b515d0f 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, @@ -28,7 +28,7 @@ import { QueueName, } from 'src/interfaces/job.interface'; import { BaseService } from 'src/services/base.service'; -import { AudioStreamInfo, VideoFormat, VideoInterfaces, VideoStreamInfo } from 'src/types'; +import { AudioStreamInfo, DecodeToBufferOptions, 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'; @@ -135,6 +135,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 +156,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 +171,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 +181,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 +200,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))); } @@ -209,33 +228,64 @@ 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'; - - 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), - ]); + const shouldConvertFullsize = image.fullsize.enabled && !mimeTypes.isWebSupportedImage(asset.originalFileName); + const shouldExtractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName); + const decodeOptions: DecodeToBufferOptions = { colorspace, processInvalidImages, size: image.preview.size }; + + let useExtracted = false; + let decodeInputPath: string = asset.originalPath; + // Converted or extracted image from non-web-supported formats (e.g. RAW) + let fullsizePath: string | undefined; + + if (shouldConvertFullsize) { + // unset size to decode fullsize image + decodeOptions.size = undefined; + fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, image.preview.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)); - return { previewPath, thumbnailPath, thumbhash: outputs[2] }; - } finally { - if (didExtract) { - await this.storageRepository.unlink(extractedPath); + 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 = { ...image.fullsize, ...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 02166cdeb830b..5412ef50a9c5f 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -149,6 +149,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 9928669136b73..45b2c00b8d777 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -84,6 +84,12 @@ export interface CropOptions { height: number; } +export interface FullsizeImageOptions { + format: ImageFormat; + quality: number; + enabled: boolean; +} + export interface ImageOptions { format: ImageFormat; quality: number; @@ -104,11 +110,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 39593a77f3e23..ff929d715f0b3 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 4f3009e39f3e3..3b44ff70b47ff 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 6e1b4f083b170..22a3244f9043a 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)), @@ -99,6 +113,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 8f6c794790a4a..7316c49bf20cf 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -774,7 +774,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 928a7956c5f0c..3475a9914a137 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -38,5 +38,6 @@ export const newAssetRepositoryMock = (): Mocked => { getDuplicates: vitest.fn(), upsertFile: vitest.fn(), upsertFiles: vitest.fn(), + deleteFiles: vitest.fn(), }; }; diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index 1e909dcae31bb..0e1d98c86d957 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..9a66ad9c97d04 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 e1372e37daa4b..f6066a2a41aff 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts +++ b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts @@ -49,8 +49,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, 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 bad8d3c404e90..3c8d00b5edb41 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));