feat: image editing (#24155)

This commit is contained in:
Brandon Wees
2026-01-09 17:59:52 -05:00
committed by GitHub
parent 76241a7b2b
commit e8c80d88a5
141 changed files with 7836 additions and 1634 deletions

View File

@@ -1,43 +1,61 @@
import { AssetFace, AssetFile, Exif } from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetEditAction, AssetEditActionItem } from 'src/dtos/editing.dto';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { StorageAsset } from 'src/types';
import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { userStub } from 'test/fixtures/user.stub';
import { factory } from 'test/small.factory';
export const previewFile: AssetFile = {
id: 'file-1',
type: AssetFileType.Preview,
path: '/uploads/user-id/thumbs/path.jpg',
};
export const previewFile = factory.assetFile({ type: AssetFileType.Preview });
const thumbnailFile: AssetFile = {
id: 'file-2',
const thumbnailFile = factory.assetFile({
type: AssetFileType.Thumbnail,
path: '/uploads/user-id/webp/path.ext',
};
});
const fullsizeFile: AssetFile = {
id: 'file-3',
const fullsizeFile = factory.assetFile({
type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/path.webp',
};
});
const sidecarFileWithExt: AssetFile = {
id: 'sidecar-with-ext',
const sidecarFileWithExt = factory.assetFile({
type: AssetFileType.Sidecar,
path: '/original/path.ext.xmp',
};
});
const sidecarFileWithoutExt: AssetFile = {
id: 'sidecar-without-ext',
const sidecarFileWithoutExt = factory.assetFile({
type: AssetFileType.Sidecar,
path: '/original/path.xmp',
};
});
const editedPreviewFile = factory.assetFile({
type: AssetFileType.PreviewEdited,
path: '/uploads/user-id/preview/path_edited.jpg',
});
const editedThumbnailFile = factory.assetFile({
type: AssetFileType.ThumbnailEdited,
path: '/uploads/user-id/thumbnail/path_edited.jpg',
});
const editedFullsizeFile = factory.assetFile({
type: AssetFileType.FullSizeEdited,
path: '/uploads/user-id/fullsize/path_edited.jpg',
});
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
const editedFiles: AssetFile[] = [
fullsizeFile,
previewFile,
thumbnailFile,
editedFullsizeFile,
editedPreviewFile,
editedThumbnailFile,
];
export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => {
return {
id: stackId,
@@ -104,6 +122,9 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
noWebpPath: Object.freeze({
@@ -142,6 +163,9 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
noThumbhash: Object.freeze({
@@ -177,6 +201,9 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
primaryImage: Object.freeze({
@@ -222,6 +249,9 @@ export const assetStub = {
updateId: '42',
libraryId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
image: Object.freeze({
@@ -264,9 +294,10 @@ export const assetStub = {
stack: null,
orientation: '',
projectionType: null,
height: 3840,
width: 2160,
height: null,
width: null,
visibility: AssetVisibility.Timeline,
edits: [],
}),
trashed: Object.freeze({
@@ -307,6 +338,9 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
trashedOffline: Object.freeze({
@@ -347,6 +381,9 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
archived: Object.freeze({
id: 'asset-id',
@@ -386,6 +423,9 @@ export const assetStub = {
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
external: Object.freeze({
@@ -425,6 +465,9 @@ export const assetStub = {
stackId: null,
stack: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
image1: Object.freeze({
@@ -464,6 +507,9 @@ export const assetStub = {
libraryId: null,
stack: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
imageFrom2015: Object.freeze({
@@ -502,6 +548,9 @@ export const assetStub = {
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
video: Object.freeze({
@@ -542,6 +591,9 @@ export const assetStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
livePhotoMotionAsset: Object.freeze({
@@ -559,7 +611,10 @@ export const assetStub = {
files: [] as AssetFile[],
libraryId: null,
visibility: AssetVisibility.Hidden,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }),
width: null,
height: null,
edits: [] as AssetEditActionItem[],
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif; edits: AssetEditActionItem[] }),
livePhotoStillAsset: Object.freeze({
id: 'live-photo-still-asset',
@@ -577,7 +632,10 @@ export const assetStub = {
files,
faces: [] as AssetFace[],
visibility: AssetVisibility.Timeline,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[] }),
width: null,
height: null,
edits: [] as AssetEditActionItem[],
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }),
livePhotoWithOriginalFileName: Object.freeze({
id: 'live-photo-still-asset',
@@ -597,7 +655,10 @@ export const assetStub = {
libraryId: null,
faces: [] as AssetFace[],
visibility: AssetVisibility.Timeline,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[] }),
width: null,
height: null,
edits: [] as AssetEditActionItem[],
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }),
withLocation: Object.freeze({
id: 'asset-with-favorite-id',
@@ -641,6 +702,9 @@ export const assetStub = {
isOffline: false,
tags: [],
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
sidecar: Object.freeze({
@@ -676,6 +740,9 @@ export const assetStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
sidecarWithoutExt: Object.freeze({
@@ -708,6 +775,9 @@ export const assetStub = {
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
hasEncodedVideo: Object.freeze({
@@ -747,6 +817,9 @@ export const assetStub = {
stackId: null,
stack: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
hasFileExtension: Object.freeze({
@@ -783,6 +856,9 @@ export const assetStub = {
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
imageDng: Object.freeze({
@@ -823,6 +899,9 @@ export const assetStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
imageHif: Object.freeze({
@@ -863,6 +942,9 @@ export const assetStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
panoramaTif: Object.freeze({
id: 'asset-id',
@@ -902,5 +984,110 @@ export const assetStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
withCropEdit: 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.jpg',
files,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
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('2025-01-01T01:02:03.456Z'),
isFavorite: true,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
updateId: 'foo',
libraryId: null,
stackId: null,
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
exifImageWidth: 2160,
} as Exif,
duplicateId: null,
isOffline: false,
stack: null,
orientation: '',
projectionType: null,
height: 3840,
width: 2160,
visibility: AssetVisibility.Timeline,
edits: [
{
action: AssetEditAction.Crop,
parameters: {
width: 1512,
height: 1152,
x: 216,
y: 1512,
},
},
] as AssetEditActionItem[],
}),
withoutEdits: 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.jpg',
files: editedFiles,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
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('2025-01-01T01:02:03.456Z'),
isFavorite: true,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
updateId: 'foo',
libraryId: null,
stackId: null,
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
exifImageWidth: 2160,
} as Exif,
duplicateId: null,
isOffline: false,
stack: null,
orientation: '',
projectionType: null,
height: 3840,
width: 2160,
visibility: AssetVisibility.Timeline,
edits: [],
}),
};

View File

@@ -25,6 +25,7 @@ export const faceStub = {
deletedAt: new Date(),
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
primaryFace1: Object.freeze({
id: 'assetFaceId2',
@@ -43,6 +44,7 @@ export const faceStub = {
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
mergeFace1: Object.freeze({
id: 'assetFaceId3',
@@ -61,6 +63,7 @@ export const faceStub = {
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
noPerson1: Object.freeze({
id: 'assetFaceId8',
@@ -79,6 +82,7 @@ export const faceStub = {
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
noPerson2: Object.freeze({
id: 'assetFaceId9',
@@ -97,6 +101,7 @@ export const faceStub = {
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
fromExif1: Object.freeze({
id: 'assetFaceId9',
@@ -114,6 +119,7 @@ export const faceStub = {
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
fromExif2: Object.freeze({
id: 'assetFaceId9',
@@ -131,6 +137,7 @@ export const faceStub = {
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
withBirthDate: Object.freeze({
id: 'assetFaceId10',
@@ -148,5 +155,6 @@ export const faceStub = {
deletedAt: null,
updatedAt: new Date('2023-01-01T00:00:00Z'),
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
isVisible: true,
}),
};

View File

@@ -142,6 +142,11 @@ export const sharedLinkStub = {
rating: 3,
updatedAt: today,
updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: 500,
height: 500,
},
sharedLinks: [],
faces: [],
@@ -152,6 +157,8 @@ export const sharedLinkStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: 500,
height: 500,
},
],
albumId: null,

View File

@@ -581,6 +581,7 @@ const assetFaceInsert = (assetFace: Partial<AssetFace> & { assetId: string }) =>
imageWidth: assetFace.imageWidth ?? 10,
personId: assetFace.personId ?? null,
sourceType: assetFace.sourceType ?? SourceType.MachineLearning,
isVisible: assetFace.isVisible ?? true,
};
return {

View File

@@ -57,6 +57,7 @@ describe(OcrService.name, () => {
id: expect.any(String),
text: 'Test OCR',
textScore: 0.95,
isVisible: true,
x1: 10,
y1: 10,
x2: 50,
@@ -106,6 +107,7 @@ describe(OcrService.name, () => {
id: expect.any(String),
text: 'One',
textScore: 0.9,
isVisible: true,
x1: 0,
y1: 1,
x2: 2,
@@ -121,6 +123,7 @@ describe(OcrService.name, () => {
id: expect.any(String),
text: 'Two',
textScore: 0.89,
isVisible: true,
x1: 8,
y1: 9,
x2: 10,
@@ -136,6 +139,7 @@ describe(OcrService.name, () => {
id: expect.any(String),
text: 'Three',
textScore: 0.88,
isVisible: true,
x1: 16,
y1: 17,
x2: 18,
@@ -151,6 +155,7 @@ describe(OcrService.name, () => {
id: expect.any(String),
text: 'Four',
textScore: 0.87,
isVisible: true,
x1: 24,
y1: 25,
x2: 26,
@@ -166,6 +171,7 @@ describe(OcrService.name, () => {
id: expect.any(String),
text: 'Five',
textScore: 0.86,
isVisible: true,
x1: 32,
y1: 33,
x2: 34,

View File

@@ -52,6 +52,8 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
livePhotoVideoId: null,
stackId: null,
libraryId: null,
width: 1920,
height: 1080,
});
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
@@ -79,6 +81,8 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
livePhotoVideoId: asset.livePhotoVideoId,
stackId: asset.stackId,
libraryId: asset.libraryId,
width: asset.width,
height: asset.height,
},
type: SyncEntityType.AlbumAssetCreateV1,
},

View File

@@ -37,6 +37,8 @@ describe(SyncEntityType.AssetV1, () => {
deletedAt: null,
duration: '0:10:00.00000',
libraryId: null,
width: 1920,
height: 1080,
});
const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]);
@@ -60,6 +62,8 @@ describe(SyncEntityType.AssetV1, () => {
stackId: null,
livePhotoVideoId: null,
libraryId: asset.libraryId,
width: asset.width,
height: asset.height,
},
type: 'AssetV1',
},

View File

@@ -66,6 +66,8 @@ describe(SyncRequestType.PartnerAssetsV1, () => {
stackId: null,
livePhotoVideoId: null,
libraryId: asset.libraryId,
width: null,
height: null,
},
type: SyncEntityType.PartnerAssetV1,
},

View File

@@ -1,6 +1,7 @@
import {
Activity,
ApiKey,
AssetFile,
AuthApiKey,
AuthSharedLink,
AuthUser,
@@ -250,6 +251,8 @@ const assetFactory = (asset: Partial<MapAsset> = {}) => ({
thumbhash: null,
type: AssetType.Image,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
...asset,
});
@@ -358,6 +361,7 @@ const assetOcrFactory = (
boxScore?: number;
textScore?: number;
text?: string;
isVisible?: boolean;
} = {},
) => ({
id: newUuid(),
@@ -373,13 +377,22 @@ const assetOcrFactory = (
boxScore: 0.95,
textScore: 0.92,
text: 'Sample Text',
isVisible: true,
...ocr,
});
const assetFileFactory = (file: Partial<AssetFile> = {}): AssetFile => ({
id: newUuid(),
type: AssetFileType.Preview,
path: '/uploads/user-id/thumbs/path.jpg',
...file,
});
export const factory = {
activity: activityFactory,
apiKey: apiKeyFactory,
asset: assetFactory,
assetFile: assetFileFactory,
assetOcr: assetOcrFactory,
auth: authFactory,
authApiKey: authApiKeyFactory,

View File

@@ -20,6 +20,7 @@ import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AppRepository } from 'src/repositories/app.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 { AuditRepository } from 'src/repositories/audit.repository';
@@ -216,6 +217,7 @@ export type ServiceOverrides = {
app: AppRepository;
audit: AuditRepository;
asset: AssetRepository;
assetEdit: AssetEditRepository;
assetJob: AssetJobRepository;
config: ConfigRepository;
cron: CronRepository;
@@ -289,6 +291,7 @@ export const getMocks = () => {
album: automock(AlbumRepository, { strict: false }),
albumUser: automock(AlbumUserRepository),
asset: newAssetRepositoryMock(),
assetEdit: automock(AssetEditRepository),
assetJob: automock(AssetJobRepository),
app: automock(AppRepository, { strict: false }),
config: newConfigRepositoryMock(),
@@ -356,6 +359,7 @@ export const newTestService = <T extends BaseService>(
overrides.apiKey || (mocks.apiKey as As<ApiKeyRepository>),
overrides.app || (mocks.app as As<AppRepository>),
overrides.asset || (mocks.asset as As<AssetRepository>),
overrides.assetEdit || (mocks.assetEdit as As<AssetEditRepository>),
overrides.assetJob || (mocks.assetJob as As<AssetJobRepository>),
overrides.audit || (mocks.audit as As<AuditRepository>),
overrides.config || (mocks.config as As<ConfigRepository> as ConfigRepository),