diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index cb40d3eec4..c6821404dc 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -34,6 +34,8 @@ export interface MoveRequest { export type ThumbnailPathEntity = { id: string; ownerId: string }; +export type ImagePathOptions = { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean }; + let instance: StorageCore | null; let mediaLocation: string | undefined; @@ -110,14 +112,7 @@ export class StorageCore { return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`); } - static getImagePath( - asset: ThumbnailPathEntity, - { - fileType, - format, - isEdited, - }: { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean }, - ) { + static getImagePath(asset: ThumbnailPathEntity, { fileType, format, isEdited }: ImagePathOptions) { return StorageCore.getNestedPath( StorageFolder.Thumbnails, asset.ownerId, diff --git a/server/src/database.ts b/server/src/database.ts index 60b6cc40aa..dd979fdea6 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -346,6 +346,13 @@ export const columns = { 'asset.height', ], assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'], + assetFilesForThumbnail: [ + 'asset_file.id', + 'asset_file.path', + 'asset_file.type', + 'asset_file.isEdited', + 'asset_file.isProgressive', + ], authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'], authApiKey: ['api_key.id', 'api_key.permissions'], authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'], diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index bb9941a162..fae1123167 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -165,11 +165,13 @@ select "asset_file"."id", "asset_file"."path", "asset_file"."type", - "asset_file"."isEdited" + "asset_file"."isEdited", + "asset_file"."isProgressive" from "asset_file" where "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" in ($1, $2, $3) ) as agg ) as "files", ( @@ -191,7 +193,7 @@ from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where - "asset"."id" = $1 + "asset"."id" = $4 -- AssetJobRepository.getForMetadataExtraction select diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index b5cb4a537b..713f715c5c 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Kysely } from 'kysely'; +import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { Asset, columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; @@ -104,7 +105,15 @@ export class AssetJobRepository { 'asset.thumbhash', 'asset.type', ]) - .select(withFiles) + .select((eb) => + jsonArrayFrom( + eb + .selectFrom('asset_file') + .select(columns.assetFilesForThumbnail) + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', 'in', [AssetFileType.Thumbnail, AssetFileType.Preview, AssetFileType.FullSize]), + ).as('files'), + ) .select(withEdits) .$call(withExifInner) .where('asset.id', '=', id) diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 6655184597..c1340754ea 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -904,11 +904,12 @@ export class AssetRepository { .execute(); } - async upsertFile(file: Pick, 'assetId' | 'path' | 'type' | 'isEdited'>): Promise { - const value = { ...file, assetId: asUuid(file.assetId) }; + async upsertFile( + file: Pick, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>, + ): Promise { await this.db .insertInto('asset_file') - .values(value) + .values(file) .onConflict((oc) => oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({ path: eb.ref('excluded.path'), @@ -918,19 +919,19 @@ export class AssetRepository { } async upsertFiles( - files: Pick, 'assetId' | 'path' | 'type' | 'isEdited'>[], + files: Pick, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>[], ): Promise { if (files.length === 0) { return; } - const values = files.map((row) => ({ ...row, assetId: asUuid(row.assetId) })); await this.db .insertInto('asset_file') - .values(values) + .values(files) .onConflict((oc) => oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({ path: eb.ref('excluded.path'), + isProgressive: eb.ref('excluded.isProgressive'), })), ) .execute(); diff --git a/server/src/schema/migrations/1769441657564-AddIsProgressiveColumn.ts b/server/src/schema/migrations/1769441657564-AddIsProgressiveColumn.ts new file mode 100644 index 0000000000..6377dc1059 --- /dev/null +++ b/server/src/schema/migrations/1769441657564-AddIsProgressiveColumn.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "asset_file" ADD "isProgressive" boolean NOT NULL DEFAULT false;`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "asset_file" DROP COLUMN "isProgressive";`.execute(db); +} diff --git a/server/src/schema/tables/asset-file.table.ts b/server/src/schema/tables/asset-file.table.ts index 41301f41b7..73b5171a47 100644 --- a/server/src/schema/tables/asset-file.table.ts +++ b/server/src/schema/tables/asset-file.table.ts @@ -40,4 +40,7 @@ export class AssetFileTable { @Column({ type: 'boolean', default: false }) isEdited!: Generated; + + @Column({ type: 'boolean', default: false }) + isProgressive!: Generated; } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 8e6440eb7a..fa2607faa9 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -388,12 +388,14 @@ describe(MediaService.name, () => { type: AssetFileType.Preview, path: expect.any(String), isEdited: false, + isProgressive: false, }, { assetId: 'asset-id', type: AssetFileType.Thumbnail, path: expect.any(String), isEdited: false, + isProgressive: false, }, ]); expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); @@ -426,12 +428,14 @@ describe(MediaService.name, () => { type: AssetFileType.Preview, path: expect.any(String), isEdited: false, + isProgressive: false, }, { assetId: 'asset-id', type: AssetFileType.Thumbnail, path: expect.any(String), isEdited: false, + isProgressive: false, }, ]); }); @@ -463,12 +467,14 @@ describe(MediaService.name, () => { type: AssetFileType.Preview, path: expect.any(String), isEdited: false, + isProgressive: false, }, { assetId: 'asset-id', type: AssetFileType.Thumbnail, path: expect.any(String), isEdited: false, + isProgressive: false, }, ]); }); @@ -673,6 +679,16 @@ describe(MediaService.name, () => { }), expect.stringContaining('thumbnail.webp'), ); + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ + expect.objectContaining({ + type: AssetFileType.Preview, + isProgressive: true, + }), + expect.objectContaining({ + type: AssetFileType.Thumbnail, + isProgressive: false, + }), + ]); }); it('should generate progressive JPEG for thumbnail when enabled', async () => { @@ -699,6 +715,37 @@ describe(MediaService.name, () => { }), expect.stringContaining('thumbnail.jpeg'), ); + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ + expect.objectContaining({ + type: AssetFileType.Preview, + isProgressive: false, + }), + expect.objectContaining({ + type: AssetFileType.Thumbnail, + isProgressive: true, + }), + ]); + }); + + it('should never set isProgressive for videos', async () => { + mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.systemMetadata.get.mockResolvedValue({ + image: { preview: { progressive: true }, thumbnail: { progressive: true } }, + }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); + + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); + + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ + expect.objectContaining({ + type: AssetFileType.Preview, + isProgressive: false, + }), + expect.objectContaining({ + type: AssetFileType.Thumbnail, + isProgressive: false, + }), + ]); }); it('should delete previous thumbnail if different path', async () => { @@ -3353,14 +3400,38 @@ describe(MediaService.name, () => { files: [], }; - await sut['syncFiles'](asset, [ - { type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false }, - { type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false }, + await sut['syncFiles'](asset.files, [ + { + assetId: asset.id, + type: AssetFileType.Preview, + path: '/new/preview.jpg', + isEdited: false, + isProgressive: false, + }, + { + assetId: asset.id, + type: AssetFileType.Thumbnail, + path: '/new/thumbnail.jpg', + isEdited: false, + isProgressive: false, + }, ]); expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ - { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false }, - { assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false }, + { + assetId: 'asset-id', + path: '/new/preview.jpg', + type: AssetFileType.Preview, + isEdited: false, + isProgressive: false, + }, + { + assetId: 'asset-id', + path: '/new/thumbnail.jpg', + type: AssetFileType.Thumbnail, + isEdited: false, + isProgressive: false, + }, ]); expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); expect(mocks.job.queue).not.toHaveBeenCalled(); @@ -3376,6 +3447,7 @@ describe(MediaService.name, () => { type: AssetFileType.Preview, path: '/old/preview.jpg', isEdited: false, + isProgressive: false, }, { id: 'file-2', @@ -3383,18 +3455,43 @@ describe(MediaService.name, () => { type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg', isEdited: false, + isProgressive: false, }, ], }; - await sut['syncFiles'](asset, [ - { type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false }, - { type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false }, + await sut['syncFiles'](asset.files, [ + { + assetId: asset.id, + type: AssetFileType.Preview, + path: '/new/preview.jpg', + isEdited: false, + isProgressive: false, + }, + { + assetId: asset.id, + type: AssetFileType.Thumbnail, + path: '/new/thumbnail.jpg', + isEdited: false, + isProgressive: false, + }, ]); expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ - { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false }, - { assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false }, + { + assetId: 'asset-id', + path: '/new/preview.jpg', + type: AssetFileType.Preview, + isEdited: false, + isProgressive: false, + }, + { + assetId: 'asset-id', + path: '/new/thumbnail.jpg', + type: AssetFileType.Thumbnail, + isEdited: false, + isProgressive: false, + }, ]); expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); expect(mocks.job.queue).toHaveBeenCalledWith({ @@ -3413,6 +3510,7 @@ describe(MediaService.name, () => { type: AssetFileType.Preview, path: '/old/preview.jpg', isEdited: false, + isProgressive: false, }, { id: 'file-2', @@ -3420,24 +3518,30 @@ describe(MediaService.name, () => { type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg', isEdited: false, + isProgressive: false, }, ], }; - await sut['syncFiles'](asset, [ - { type: AssetFileType.Preview, isEdited: false }, - { type: AssetFileType.Thumbnail, isEdited: false }, - ]); + await sut['syncFiles'](asset.files, []); expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([ - { id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg', isEdited: false }, + { + id: 'file-1', + assetId: 'asset-id', + type: AssetFileType.Preview, + path: '/old/preview.jpg', + isEdited: false, + isProgressive: false, + }, { id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg', isEdited: false, + isProgressive: false, }, ]); expect(mocks.job.queue).toHaveBeenCalledWith({ @@ -3456,6 +3560,7 @@ describe(MediaService.name, () => { type: AssetFileType.Preview, path: '/same/preview.jpg', isEdited: false, + isProgressive: false, }, { id: 'file-2', @@ -3463,13 +3568,26 @@ describe(MediaService.name, () => { type: AssetFileType.Thumbnail, path: '/same/thumbnail.jpg', isEdited: false, + isProgressive: false, }, ], }; - await sut['syncFiles'](asset, [ - { type: AssetFileType.Preview, newPath: '/same/preview.jpg', isEdited: false }, - { type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg', isEdited: false }, + await sut['syncFiles'](asset.files, [ + { + assetId: asset.id, + type: AssetFileType.Preview, + path: '/same/preview.jpg', + isEdited: false, + isProgressive: false, + }, + { + assetId: asset.id, + type: AssetFileType.Thumbnail, + path: '/same/thumbnail.jpg', + isEdited: false, + isProgressive: false, + }, ]); expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); @@ -3487,6 +3605,7 @@ describe(MediaService.name, () => { type: AssetFileType.Preview, path: '/old/preview.jpg', isEdited: false, + isProgressive: false, }, { id: 'file-2', @@ -3494,19 +3613,43 @@ describe(MediaService.name, () => { type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg', isEdited: false, + isProgressive: false, }, ], }; - await sut['syncFiles'](asset, [ - { type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false }, // replace - { type: AssetFileType.Thumbnail, isEdited: false }, // delete - { type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg', isEdited: false }, // new + await sut['syncFiles'](asset.files, [ + { + assetId: asset.id, + type: AssetFileType.Preview, + path: '/new/preview.jpg', + isEdited: false, + isProgressive: false, + }, // replace + { + assetId: asset.id, + type: AssetFileType.FullSize, + path: '/new/fullsize.jpg', + isEdited: false, + isProgressive: false, + }, // new ]); expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ - { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false }, - { assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize, isEdited: false }, + { + assetId: 'asset-id', + path: '/new/preview.jpg', + type: AssetFileType.Preview, + isEdited: false, + isProgressive: false, + }, + { + assetId: 'asset-id', + path: '/new/fullsize.jpg', + type: AssetFileType.FullSize, + isEdited: false, + isProgressive: false, + }, ]); expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([ { @@ -3515,6 +3658,7 @@ describe(MediaService.name, () => { type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg', isEdited: false, + isProgressive: false, }, ]); expect(mocks.job.queue).toHaveBeenCalledWith({ @@ -3529,7 +3673,7 @@ describe(MediaService.name, () => { files: [], }; - await sut['syncFiles'](asset, []); + await sut['syncFiles'](asset.files, []); expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); @@ -3546,15 +3690,79 @@ describe(MediaService.name, () => { type: AssetFileType.Preview, path: '/old/preview.jpg', isEdited: false, + isProgressive: false, }, ], }; - await sut['syncFiles'](asset, [ - { type: AssetFileType.Thumbnail, isEdited: false }, // file doesn't exist, newPath not provided - ]); + await sut['syncFiles'](asset.files, []); expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); + expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([ + { + id: 'file-1', + assetId: 'asset-id', + type: AssetFileType.Preview, + path: '/old/preview.jpg', + isEdited: false, + isProgressive: false, + }, + ]); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.FileDelete, + data: { files: ['/old/preview.jpg'] }, + }); + }); + + it('should update database when isProgressive changes', async () => { + const asset = { + id: 'asset-id', + files: [ + { + id: 'file-1', + assetId: 'asset-id', + type: AssetFileType.Preview, + path: '/old/preview.jpg', + isEdited: false, + isProgressive: false, + }, + { + id: 'file-2', + assetId: 'asset-id', + type: AssetFileType.Thumbnail, + path: '/old/thumbnail.jpg', + isEdited: false, + isProgressive: false, + }, + ], + }; + + await sut['syncFiles'](asset.files, [ + { + assetId: asset.id, + type: AssetFileType.Preview, + path: '/old/preview.jpg', + isEdited: false, + isProgressive: true, + }, + { + assetId: asset.id, + type: AssetFileType.Thumbnail, + path: '/old/thumbnail.jpg', + isEdited: false, + isProgressive: false, + }, + ]); + + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + path: '/old/preview.jpg', + type: AssetFileType.Preview, + isEdited: false, + isProgressive: true, + }, + ]); expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); expect(mocks.job.queue).not.toHaveBeenCalled(); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 8684b78c2f..f4a173fded 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; -import { StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core'; +import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core'; import { AssetFile, Exif } from 'src/database'; import { OnEvent, OnJob } from 'src/decorators'; import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto'; @@ -45,11 +45,13 @@ import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; import { clamp, isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc'; import { getOutputDimensions } from 'src/utils/transform'; + interface UpsertFileOptions { assetId: string; type: AssetFileType; path: string; isEdited: boolean; + isProgressive: boolean; } type ThumbnailAsset = NonNullable>>; @@ -171,18 +173,22 @@ export class MediaService extends BaseService { @OnJob({ name: JobName.AssetEditThumbnailGeneration, queue: QueueName.Editor }) async handleAssetEditThumbnailGeneration({ id }: JobOf): Promise { const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id); + const config = await this.getConfig({ withCache: true }); if (!asset) { this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`); return JobStatus.Failed; } - const generated = await this.generateEditedThumbnails(asset); + const generated = await this.generateEditedThumbnails(asset, config); + await this.syncFiles( + asset.files.filter((asset) => asset.isEdited), + generated?.files ?? [], + ); let thumbhash: Buffer | undefined = generated?.thumbhash; if (!thumbhash) { - const { image } = await this.getConfig({ withCache: true }); - const extractedImage = await this.extractOriginalImage(asset, image); + const extractedImage = await this.extractOriginalImage(asset, config.image); const { info, data, colorspace } = extractedImage; thumbhash = await this.mediaRepository.generateThumbhash(data, { @@ -206,6 +212,7 @@ export class MediaService extends BaseService { @OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration }) async handleGenerateThumbnails({ id }: JobOf): Promise { const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id); + const config = await this.getConfig({ withCache: true }); if (!asset) { this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`); @@ -217,32 +224,25 @@ export class MediaService extends BaseService { return JobStatus.Skipped; } - let generated: { - previewPath: string; - thumbnailPath: string; - fullsizePath?: string; - thumbhash: Buffer; - fullsizeDimensions?: ImageDimensions; - }; + let generated: Awaited>; if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) { this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`); - generated = await this.generateVideoThumbnails(asset); + generated = await this.generateVideoThumbnails(asset, config); } else if (asset.type === AssetType.Image) { this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`); - generated = await this.generateImageThumbnails(asset); + generated = await this.generateImageThumbnails(asset, config); } else { this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`); return JobStatus.Skipped; } - await this.syncFiles(asset, [ - { type: AssetFileType.Preview, newPath: generated.previewPath, isEdited: false }, - { type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath, isEdited: false }, - { type: AssetFileType.FullSize, newPath: generated.fullsizePath, isEdited: false }, - ]); + const editedGenerated = await this.generateEditedThumbnails(asset, config); + if (editedGenerated) { + generated.files.push(...editedGenerated.files); + } - const editiedGenerated = await this.generateEditedThumbnails(asset); - const thumbhash = editiedGenerated?.thumbhash || generated.thumbhash; + await this.syncFiles(asset.files, generated.files); + const thumbhash = editedGenerated?.thumbhash || generated.thumbhash; if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) { await this.assetRepository.update({ id: asset.id, thumbhash }); @@ -274,11 +274,7 @@ export class MediaService extends BaseService { return { info, data, colorspace }; } - private async extractOriginalImage( - asset: NonNullable, - image: SystemConfig['image'], - useEdits = false, - ) { + private async extractOriginalImage(asset: ThumbnailAsset, image: SystemConfig['image'], useEdits = false) { const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName); const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null; const generateFullsize = @@ -305,19 +301,21 @@ export class MediaService extends BaseService { }; } - private async generateImageThumbnails(asset: ThumbnailAsset, useEdits: boolean = false) { - const { image } = await this.getConfig({ withCache: true }); - const previewPath = StorageCore.getImagePath(asset, { + private async generateImageThumbnails(asset: ThumbnailAsset, { image }: SystemConfig, useEdits: boolean = false) { + const previewFile = this.getImageFile(asset, { fileType: AssetFileType.Preview, - isEdited: useEdits, format: image.preview.format, - }); - const thumbnailPath = StorageCore.getImagePath(asset, { - fileType: AssetFileType.Thumbnail, isEdited: useEdits, - format: image.thumbnail.format, + isProgressive: !!image.preview.progressive && image.preview.format !== ImageFormat.Webp, }); - this.storageCore.ensureFolders(previewPath); + previewFile.isProgressive = !!image.preview.progressive && image.preview.format !== ImageFormat.Webp; + const thumbnailFile = this.getImageFile(asset, { + fileType: AssetFileType.Thumbnail, + format: image.thumbnail.format, + isEdited: useEdits, + isProgressive: !!image.thumbnail.progressive && image.thumbnail.format !== ImageFormat.Webp, + }); + this.storageCore.ensureFolders(previewFile.path); // Handle embedded preview extraction for RAW files const extractedImage = await this.extractOriginalImage(asset, image, useEdits); @@ -327,26 +325,18 @@ export class MediaService extends BaseService { const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] }; const promises = [ this.mediaRepository.generateThumbhash(data, thumbnailOptions), - this.mediaRepository.generateThumbnail( - data, - { ...image.thumbnail, ...thumbnailOptions, edits: useEdits ? asset.edits : [] }, - thumbnailPath, - ), - this.mediaRepository.generateThumbnail( - data, - { ...image.preview, ...thumbnailOptions, edits: useEdits ? asset.edits : [] }, - previewPath, - ), + this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailFile.path), + this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewFile.path), ]; - let fullsizePath: string | undefined; - + let fullsizeFile: UpsertFileOptions | undefined; if (convertFullsize) { // convert a new fullsize image from the same source as the thumbnail - fullsizePath = StorageCore.getImagePath(asset, { + fullsizeFile = this.getImageFile(asset, { fileType: AssetFileType.FullSize, - isEdited: useEdits, format: image.fullsize.format, + isEdited: useEdits, + isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp, }); const fullsizeOptions = { format: image.fullsize.format, @@ -354,23 +344,25 @@ export class MediaService extends BaseService { progressive: image.fullsize.progressive, ...thumbnailOptions, }; - promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath)); + promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizeFile.path)); } else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) { - fullsizePath = StorageCore.getImagePath(asset, { + fullsizeFile = this.getImageFile(asset, { fileType: AssetFileType.FullSize, format: extracted.format, - isEdited: false, + isEdited: useEdits, + isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp, }); - this.storageCore.ensureFolders(fullsizePath); + fullsizeFile.isProgressive = !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp; + this.storageCore.ensureFolders(fullsizeFile.path); // Write the buffer to disk with essential EXIF data - await this.storageRepository.createOrOverwriteFile(fullsizePath, extracted.buffer); + await this.storageRepository.createOrOverwriteFile(fullsizeFile.path, extracted.buffer); await this.mediaRepository.writeExif( { orientation: asset.exifInfo.orientation, colorspace: asset.exifInfo.colorspace, }, - fullsizePath, + fullsizeFile.path, ); } @@ -378,9 +370,9 @@ export class MediaService extends BaseService { if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') { const promises = [ - this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewPath), - fullsizePath - ? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizePath) + this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewFile.path), + fullsizeFile + ? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizeFile.path) : Promise.resolve(), ]; await Promise.all(promises); @@ -389,7 +381,11 @@ export class MediaService extends BaseService { const decodedDimensions = { width: info.width, height: info.height }; const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions; - return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer, fullsizeDimensions }; + return { + files: fullsizeFile ? [previewFile, thumbnailFile, fullsizeFile] : [previewFile, thumbnailFile], + thumbhash: outputs[0] as Buffer, + fullsizeDimensions, + }; } @OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration }) @@ -493,19 +489,23 @@ export class MediaService extends BaseService { }; } - private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) { - const { image, ffmpeg } = await this.getConfig({ withCache: true }); - const previewPath = StorageCore.getImagePath(asset, { + private async generateVideoThumbnails( + asset: ThumbnailPathEntity & { originalPath: string }, + { ffmpeg, image }: SystemConfig, + ) { + const previewFile = this.getImageFile(asset, { fileType: AssetFileType.Preview, format: image.preview.format, isEdited: false, + isProgressive: false, }); - const thumbnailPath = StorageCore.getImagePath(asset, { + const thumbnailFile = this.getImageFile(asset, { fileType: AssetFileType.Thumbnail, format: image.thumbnail.format, isEdited: false, + isProgressive: false, }); - this.storageCore.ensureFolders(previewPath); + this.storageCore.ensureFolders(previewFile.path); const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); const mainVideoStream = this.getMainStream(videoStreams); @@ -524,17 +524,16 @@ export class MediaService extends BaseService { format, ); - await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions); - await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions); + await this.mediaRepository.transcode(asset.originalPath, previewFile.path, previewOptions); + await this.mediaRepository.transcode(asset.originalPath, thumbnailFile.path, thumbnailOptions); - const thumbhash = await this.mediaRepository.generateThumbhash(previewPath, { + const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path, { colorspace: image.colorspace, processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', }); return { - previewPath, - thumbnailPath, + files: [previewFile, thumbnailFile], thumbhash, fullsizeDimensions: { width: mainVideoStream.width, height: mainVideoStream.height }, }; @@ -791,34 +790,28 @@ export class MediaService extends BaseService { } } - private async syncFiles( - asset: { id: string; files: AssetFile[] }, - files: { type: AssetFileType; newPath?: string; isEdited: boolean }[], - ) { + private async syncFiles(oldFiles: (AssetFile & { isProgressive: boolean })[], newFiles: UpsertFileOptions[]) { const toUpsert: UpsertFileOptions[] = []; const pathsToDelete: string[] = []; - const toDelete: AssetFile[] = []; + const toDelete = new Set(oldFiles); - for (const { type, newPath, isEdited } of files) { - const existingFile = asset.files.find((file) => file.type === type && file.isEdited === isEdited); - - // upsert new file path - if (newPath && existingFile?.path !== newPath) { - toUpsert.push({ assetId: asset.id, path: newPath, type, isEdited }); - - // delete old file from disk - if (existingFile) { - this.logger.debug(`Deleting old ${type} image for asset ${asset.id} in favor of a replacement`); - pathsToDelete.push(existingFile.path); - } + for (const newFile of newFiles) { + const existingFile = oldFiles.find((file) => file.type === newFile.type && file.isEdited === newFile.isEdited); + if (existingFile) { + toDelete.delete(existingFile); } - // delete old file from disk and database - if (!newPath && existingFile) { - this.logger.debug(`Deleting old ${type} image for asset ${asset.id}`); + // upsert new file path + if (existingFile?.path !== newFile.path || existingFile.isProgressive !== newFile.isProgressive) { + toUpsert.push(newFile); - pathsToDelete.push(existingFile.path); - toDelete.push(existingFile); + // delete old file from disk + if (existingFile && existingFile.path !== newFile.path) { + this.logger.debug( + `Deleting old ${newFile.type} image for asset ${newFile.assetId} in favor of a replacement`, + ); + pathsToDelete.push(existingFile.path); + } } } @@ -826,8 +819,12 @@ export class MediaService extends BaseService { await this.assetRepository.upsertFiles(toUpsert); } - if (toDelete.length > 0) { - await this.assetRepository.deleteFiles(toDelete); + if (toDelete.size > 0) { + const toDeleteArray = [...toDelete]; + for (const file of toDeleteArray) { + pathsToDelete.push(file.path); + } + await this.assetRepository.deleteFiles(toDeleteArray); } if (pathsToDelete.length > 0) { @@ -835,18 +832,12 @@ export class MediaService extends BaseService { } } - private async generateEditedThumbnails(asset: ThumbnailAsset) { - if (asset.type !== AssetType.Image) { + private async generateEditedThumbnails(asset: ThumbnailAsset, config: SystemConfig) { + if (asset.type !== AssetType.Image || (asset.files.length === 0 && asset.edits.length === 0)) { return; } - const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, true) : undefined; - - await this.syncFiles(asset, [ - { type: AssetFileType.Preview, newPath: generated?.previewPath, isEdited: true }, - { type: AssetFileType.Thumbnail, newPath: generated?.thumbnailPath, isEdited: true }, - { type: AssetFileType.FullSize, newPath: generated?.fullsizePath, isEdited: true }, - ]); + const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, config, true) : undefined; const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop); const cropBox = crop @@ -870,4 +861,15 @@ export class MediaService extends BaseService { return generated; } + + private getImageFile(asset: ThumbnailPathEntity, options: ImagePathOptions & { isProgressive: boolean }) { + const path = StorageCore.getImagePath(asset, options); + return { + assetId: asset.id, + type: options.fileType, + path, + isEdited: options.isEdited, + isProgressive: options.isProgressive, + }; + } } diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 920b82f6e5..05219c92e7 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -48,9 +48,9 @@ const editedFullsizeFile = factory.assetFile({ isEdited: true, }); -const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile]; +const files = [fullsizeFile, previewFile, thumbnailFile]; -const editedFiles: AssetFile[] = [ +const editedFiles = [ fullsizeFile, previewFile, thumbnailFile, @@ -624,14 +624,19 @@ export const assetStub = { fileSizeInByte: 100_000, timeZone: `America/New_York`, }, - files: [] as AssetFile[], + files: [], libraryId: null, visibility: AssetVisibility.Hidden, width: null, height: null, edits: [] as AssetEditActionItem[], isEdited: false, - } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif; edits: AssetEditActionItem[] }), + } as unknown as MapAsset & { + faces: AssetFace[]; + files: (AssetFile & { isProgressive: boolean })[]; + exifInfo: Exif; + edits: AssetEditActionItem[]; + }), livePhotoStillAsset: Object.freeze({ id: 'live-photo-still-asset', @@ -653,7 +658,11 @@ export const assetStub = { height: null, edits: [] as AssetEditActionItem[], isEdited: false, - } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }), + } as unknown as MapAsset & { + faces: AssetFace[]; + files: (AssetFile & { isProgressive: boolean })[]; + edits: AssetEditActionItem[]; + }), livePhotoWithOriginalFileName: Object.freeze({ id: 'live-photo-still-asset', diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 37c5400e58..2d29386d67 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -400,11 +400,12 @@ const assetOcrFactory = ( ...ocr, }); -const assetFileFactory = (file: Partial = {}): AssetFile => ({ +const assetFileFactory = (file: Partial = {}) => ({ id: newUuid(), type: AssetFileType.Preview, path: '/uploads/user-id/thumbs/path.jpg', isEdited: false, + isProgressive: false, ...file, });