diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index e8cdd335e2..01c580fb13 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -636,3 +636,44 @@ from where "asset"."id" = $1 and "asset"."type" = $2 + +-- AssetRepository.getForOcr +select + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_edit"."action", + "asset_edit"."parameters" + from + "asset_edit" + where + "asset_edit"."assetId" = "asset"."id" + ) as agg + ) as "edits", + "asset_exif"."exifImageWidth", + "asset_exif"."exifImageHeight", + "asset_exif"."orientation" +from + "asset" + inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id" +where + "asset"."id" = $1 + +-- AssetRepository.getForEdit +select + "asset"."type", + "asset"."livePhotoVideoId", + "asset"."originalPath", + "asset"."originalFileName", + "asset_exif"."exifImageWidth", + "asset_exif"."exifImageHeight", + "asset_exif"."orientation", + "asset_exif"."projectionType" +from + "asset" + inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id" +where + "asset"."id" = $1 diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index d99d8cbab2..3165aed023 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1059,4 +1059,31 @@ export class AssetRepository { .where('asset.type', '=', AssetType.Video) .executeTakeFirst(); } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getForOcr(id: string) { + return this.db + .selectFrom('asset') + .where('asset.id', '=', id) + .select(withEdits) + .innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id')) + .select(['asset_exif.exifImageWidth', 'asset_exif.exifImageHeight', 'asset_exif.orientation']) + .executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getForEdit(id: string) { + return this.db + .selectFrom('asset') + .select(['asset.type', 'asset.livePhotoVideoId', 'asset.originalPath', 'asset.originalFileName']) + .where('asset.id', '=', id) + .innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id')) + .select([ + 'asset_exif.exifImageWidth', + 'asset_exif.exifImageHeight', + 'asset_exif.orientation', + 'asset_exif.projectionType', + ]) + .executeTakeFirst(); + } } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index b677881cfe..db895f8321 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -660,7 +660,7 @@ describe(AssetService.name, () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.ocr.getByAssetId.mockResolvedValue([ocr1, ocr2]); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForOcr.mockResolvedValue({ edits: [], ...asset.exifInfo }); await expect(sut.getOcr(authStub.admin, asset.id)).resolves.toEqual([ocr1, ocr2]); @@ -676,7 +676,7 @@ describe(AssetService.name, () => { const asset = AssetFactory.from().exif().build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.ocr.getByAssetId.mockResolvedValue([]); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForOcr.mockResolvedValue({ edits: [], ...asset.exifInfo }); await expect(sut.getOcr(authStub.admin, asset.id)).resolves.toEqual([]); expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith(asset.id); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index ed427684f1..bda2d122fe 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -404,15 +404,19 @@ export class AssetService extends BaseService { async getOcr(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); const ocr = await this.ocrRepository.getByAssetId(id); - const asset = await this.assetRepository.getById(id, { exifInfo: true, edits: true }); + const asset = await this.assetRepository.getForOcr(id); - if (!asset || !asset.exifInfo || !asset.edits) { + if (!asset) { throw new BadRequestException('Asset not found'); } - const dimensions = getDimensions(asset.exifInfo); + const dimensions = getDimensions({ + exifImageHeight: asset.exifImageHeight, + exifImageWidth: asset.exifImageWidth, + orientation: asset.orientation, + }); - return ocr.map((item) => transformOcrBoundingBox(item, asset.edits!, dimensions)); + return ocr.map((item) => transformOcrBoundingBox(item, asset.edits, dimensions)); } async upsertBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkUpsertDto): Promise { @@ -551,7 +555,7 @@ export class AssetService extends BaseService { async editAsset(auth: AuthDto, id: string, dto: AssetEditActionListDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetEditCreate, ids: [id] }); - const asset = await this.assetRepository.getById(id, { exifInfo: true }); + const asset = await this.assetRepository.getForEdit(id); if (!asset) { throw new BadRequestException('Asset not found'); } @@ -584,7 +588,7 @@ export class AssetService extends BaseService { const crop = cropIndex === -1 ? null : (dto.edits[cropIndex] as AssetEditActionCrop); if (crop) { // check that crop parameters will not go out of bounds - const { width: assetWidth, height: assetHeight } = getDimensions(asset.exifInfo!); + const { width: assetWidth, height: assetHeight } = getDimensions(asset); if (!assetWidth || !assetHeight) { throw new BadRequestException('Asset dimensions are not available for editing'); diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index f8fb3d215d..c5d1476f65 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -1,10 +1,9 @@ import { BadRequestException } from '@nestjs/common'; import { StorageCore } from 'src/cores/storage.core'; -import { AssetFile, Exif } from 'src/database'; +import { AssetFile } from 'src/database'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { ExifResponseDto } from 'src/dtos/exif.dto'; import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AccessRepository } from 'src/repositories/access.repository'; @@ -210,20 +209,26 @@ const isFlipped = (orientation?: string | null) => { return value && [5, 6, 7, 8, -90, 90].includes(value); }; -export const getDimensions = (exifInfo: ExifResponseDto | Exif) => { - const { exifImageWidth: width, exifImageHeight: height } = exifInfo; - +export const getDimensions = ({ + exifImageHeight: height, + exifImageWidth: width, + orientation, +}: { + exifImageHeight: number | null; + exifImageWidth: number | null; + orientation: string | null; +}) => { if (!width || !height) { return { width: 0, height: 0 }; } - if (isFlipped(exifInfo.orientation)) { + if (isFlipped(orientation)) { return { width: height, height: width }; } return { width, height }; }; -export const isPanorama = (asset: { exifInfo?: Exif | null; originalFileName: string }) => { - return asset.exifInfo?.projectionType === 'EQUIRECTANGULAR' || asset.originalFileName.toLowerCase().endsWith('.insp'); +export const isPanorama = (asset: { projectionType: string | null; originalFileName: string }) => { + return asset.projectionType === 'EQUIRECTANGULAR' || asset.originalFileName.toLowerCase().endsWith('.insp'); }; diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index db1b944e1f..d97551d154 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -1,12 +1,15 @@ import { Kysely } from 'kysely'; +import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetFileType, AssetMetadataKey, AssetStatus, JobName, SharedLinkType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { EventRepository } from 'src/repositories/event.repository'; import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { OcrRepository } from 'src/repositories/ocr.repository'; import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StackRepository } from 'src/repositories/stack.repository'; @@ -25,6 +28,7 @@ const setup = (db?: Kysely) => { database: db || defaultDatabase, real: [ AssetRepository, + AssetEditRepository, AssetJobRepository, AlbumRepository, AccessRepository, @@ -32,7 +36,7 @@ const setup = (db?: Kysely) => { StackRepository, UserRepository, ], - mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository], + mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository, OcrRepository], }); }; @@ -586,6 +590,57 @@ describe(AssetService.name, () => { }); }); + describe('getOcr', () => { + it('should require access', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + + await expect(sut.getOcr(auth, asset.id)).rejects.toThrow('Not found or no asset.read access'); + }); + + it('should work', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' }); + ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]); + + await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([ + expect.objectContaining({ x1: 0.1, x2: 0.3, x3: 0.3, x4: 0.1, y1: 0.2, y2: 0.2, y3: 0.4, y4: 0.4 }), + ]); + }); + + it('should apply rotation', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' }); + await ctx.database + .insertInto('asset_edit') + .values({ assetId: asset.id, action: AssetEditAction.Rotate, parameters: { angle: 90 }, sequence: 1 }) + .execute(); + ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]); + + await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([ + expect.objectContaining({ + x1: 0.6, + x2: 0.8, + x3: 0.8, + x4: 0.6, + y1: expect.any(Number), + y2: expect.any(Number), + y3: 0.3, + y4: 0.3, + }), + ]); + }); + }); + describe('upsertBulkMetadata', () => { it('should work', async () => { const { sut, ctx } = setup(); @@ -758,4 +813,38 @@ describe(AssetService.name, () => { expect(metadata).toEqual([expect.objectContaining({ key: 'some-other-key', value: { foo: 'bar' } })]); }); }); + + describe('editAsset', () => { + it('should require access', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + + await expect( + sut.editAsset(auth, asset.id, { edits: [{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }] }), + ).rejects.toThrow('Not found or no asset.edit.create access'); + }); + + it('should work', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' }); + + const editAction = { action: AssetEditAction.Rotate, parameters: { angle: 90 } } as const; + await expect(sut.editAsset(auth, asset.id, { edits: [editAction] })).resolves.toEqual({ + assetId: asset.id, + edits: [editAction], + }); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toEqual( + expect.objectContaining({ isEdited: true }), + ); + await expect(ctx.get(AssetEditRepository).getAll(asset.id)).resolves.toEqual([editAction]); + }); + }); }); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index ea7162c77a..b1ced1f874 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -54,5 +54,7 @@ export const newAssetRepositoryMock = (): Mocked