Compare commits

...

5 Commits

Author SHA1 Message Date
mertalev 80c9f29a41 formatting 2024-03-05 23:41:57 -05:00
mertalev 4410e31523 updated tests 2024-03-05 22:45:35 -05:00
mertalev c5d19b3280 use original 2024-03-05 22:45:35 -05:00
mertalev 749f4351df crop before resize 2024-03-05 22:45:35 -05:00
mertalev b34dd39978 do crop and resize together 2024-03-05 22:45:35 -05:00
8 changed files with 142 additions and 123 deletions
+43 -27
View File
@@ -202,7 +202,7 @@ describe(MediaService.name, () => {
it('should skip thumbnail generation if asset not found', async () => {
assetMock.getByIds.mockResolvedValue([]);
await sut.handleGenerateJpegThumbnail({ id: assetStub.image.id });
expect(mediaMock.resize).not.toHaveBeenCalled();
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
expect(assetMock.save).not.toHaveBeenCalledWith();
});
@@ -210,7 +210,7 @@ describe(MediaService.name, () => {
mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleGenerateJpegThumbnail({ id: assetStub.image.id });
expect(mediaMock.resize).not.toHaveBeenCalled();
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
expect(assetMock.save).not.toHaveBeenCalledWith();
});
@@ -219,12 +219,16 @@ describe(MediaService.name, () => {
await sut.handleGenerateJpegThumbnail({ id: assetStub.image.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/as/se/asset-id.jpeg', {
size: 1440,
format: 'jpeg',
quality: 80,
colorspace: Colorspace.SRGB,
});
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
'/original/path.jpg',
'upload/thumbs/user-id/as/se/asset-id.jpeg',
{
size: 1440,
format: 'jpeg',
quality: 80,
colorspace: Colorspace.SRGB,
},
);
expect(assetMock.save).toHaveBeenCalledWith({
id: 'asset-id',
resizePath: 'upload/thumbs/user-id/as/se/asset-id.jpeg',
@@ -238,12 +242,16 @@ describe(MediaService.name, () => {
await sut.handleGenerateJpegThumbnail({ id: assetStub.image.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/as/se/asset-id.jpeg', {
size: 1440,
format: 'jpeg',
quality: 80,
colorspace: Colorspace.P3,
});
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
'/original/path.jpg',
'upload/thumbs/user-id/as/se/asset-id.jpeg',
{
size: 1440,
format: 'jpeg',
quality: 80,
colorspace: Colorspace.P3,
},
);
expect(assetMock.save).toHaveBeenCalledWith({
id: 'asset-id',
resizePath: 'upload/thumbs/user-id/as/se/asset-id.jpeg',
@@ -334,7 +342,7 @@ describe(MediaService.name, () => {
it('should skip thumbnail generation if asset not found', async () => {
assetMock.getByIds.mockResolvedValue([]);
await sut.handleGenerateWebpThumbnail({ id: assetStub.image.id });
expect(mediaMock.resize).not.toHaveBeenCalled();
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
expect(assetMock.save).not.toHaveBeenCalledWith();
});
@@ -342,12 +350,16 @@ describe(MediaService.name, () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
await sut.handleGenerateWebpThumbnail({ id: assetStub.image.id });
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/as/se/asset-id.webp', {
format: 'webp',
size: 250,
quality: 80,
colorspace: Colorspace.SRGB,
});
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
'/original/path.jpg',
'upload/thumbs/user-id/as/se/asset-id.webp',
{
format: 'webp',
size: 250,
quality: 80,
colorspace: Colorspace.SRGB,
},
);
expect(assetMock.save).toHaveBeenCalledWith({
id: 'asset-id',
webpPath: 'upload/thumbs/user-id/as/se/asset-id.webp',
@@ -362,12 +374,16 @@ describe(MediaService.name, () => {
await sut.handleGenerateWebpThumbnail({ id: assetStub.image.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/as/se/asset-id.webp', {
format: 'webp',
size: 250,
quality: 80,
colorspace: Colorspace.P3,
});
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
'/original/path.jpg',
'upload/thumbs/user-id/as/se/asset-id.webp',
{
format: 'webp',
size: 250,
quality: 80,
colorspace: Colorspace.P3,
},
);
expect(assetMock.save).toHaveBeenCalledWith({
id: 'asset-id',
webpPath: 'upload/thumbs/user-id/as/se/asset-id.webp',
+1 -1
View File
@@ -185,7 +185,7 @@ export class MediaService {
case AssetType.IMAGE: {
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : thumbnail.colorspace;
const thumbnailOptions = { format, size, colorspace, quality: thumbnail.quality };
await this.mediaRepository.resize(asset.originalPath, path, thumbnailOptions);
await this.mediaRepository.generateThumbnail(asset.originalPath, path, thumbnailOptions);
break;
}
+59 -51
View File
@@ -49,8 +49,6 @@ const responseDto: PersonResponseDto = {
const statistics = { assets: 3 };
const croppedFace = Buffer.from('Cropped Face');
const detectFaceMock = {
assetId: 'asset-1',
personId: 'person-1',
@@ -105,8 +103,6 @@ describe(PersonService.name, () => {
searchMock,
cryptoMock,
);
mediaMock.crop.mockResolvedValue(croppedFace);
});
it('should be defined', () => {
@@ -916,20 +912,20 @@ describe(PersonService.name, () => {
it('should skip a person not found', async () => {
personMock.getById.mockResolvedValue(null);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mediaMock.crop).not.toHaveBeenCalled();
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
});
it('should skip a person without a face asset id', async () => {
personMock.getById.mockResolvedValue(personStub.noThumbnail);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mediaMock.crop).not.toHaveBeenCalled();
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
});
it('should skip a person with a face asset id not found', async () => {
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.id });
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mediaMock.crop).not.toHaveBeenCalled();
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
});
it('should skip a person with a face asset id without a thumbnail', async () => {
@@ -937,30 +933,34 @@ describe(PersonService.name, () => {
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1);
assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mediaMock.crop).not.toHaveBeenCalled();
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
});
it('should generate a thumbnail', async () => {
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle);
assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
assetMock.getById.mockResolvedValue(assetStub.primaryImage);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
expect(assetMock.getByIds).toHaveBeenCalledWith([faceStub.middle.assetId]);
expect(assetMock.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs');
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/admin-id/thumbs/path.jpg', {
left: 95,
top: 95,
width: 110,
height: 110,
});
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', {
format: 'jpeg',
size: 250,
quality: 80,
colorspace: Colorspace.P3,
});
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
assetStub.primaryImage.originalPath,
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
{
format: 'jpeg',
size: 250,
quality: 80,
colorspace: Colorspace.P3,
crop: {
left: 238,
top: 163,
width: 274,
height: 274,
},
},
);
expect(personMock.update).toHaveBeenCalledWith({
id: 'person-1',
thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
@@ -970,43 +970,51 @@ describe(PersonService.name, () => {
it('should generate a thumbnail without going negative', async () => {
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId });
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.start);
assetMock.getByIds.mockResolvedValue([assetStub.image]);
assetMock.getById.mockResolvedValue(assetStub.image);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', {
left: 0,
top: 0,
width: 510,
height: 510,
});
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', {
format: 'jpeg',
size: 250,
quality: 80,
colorspace: Colorspace.P3,
});
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
assetStub.image.originalPath,
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
{
format: 'jpeg',
size: 250,
quality: 80,
colorspace: Colorspace.P3,
crop: {
left: 0,
top: 428,
width: 1102,
height: 1102,
},
},
);
});
it('should generate a thumbnail without overflowing', async () => {
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId });
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end);
assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
assetMock.getById.mockResolvedValue(assetStub.primaryImage);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/admin-id/thumbs/path.jpg', {
left: 297,
top: 297,
width: 202,
height: 202,
});
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', {
format: 'jpeg',
size: 250,
quality: 80,
colorspace: Colorspace.P3,
});
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
assetStub.primaryImage.originalPath,
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
{
format: 'jpeg',
size: 250,
quality: 80,
colorspace: Colorspace.P3,
crop: {
left: 591,
top: 591,
width: 408,
height: 408,
},
},
);
});
});
+14 -10
View File
@@ -488,19 +488,23 @@ export class PersonService {
imageHeight,
} = face;
const [asset] = await this.assetRepository.getByIds([assetId]);
if (!asset?.resizePath) {
const asset = await this.assetRepository.getById(assetId, { exifInfo: true });
if (!asset?.exifInfo?.exifImageHeight || !asset?.exifInfo?.exifImageWidth) {
return false;
}
this.logger.verbose(`Cropping face for person: ${person.id}`);
const thumbnailPath = StorageCore.getPersonThumbnailPath(person);
this.storageCore.ensureFolders(thumbnailPath);
const halfWidth = (x2 - x1) / 2;
const halfHeight = (y2 - y1) / 2;
const widthScale = asset.exifInfo.exifImageWidth / imageWidth;
const heightScale = asset.exifInfo.exifImageHeight / imageHeight;
const middleX = Math.round(x1 + halfWidth);
const middleY = Math.round(y1 + halfHeight);
const halfWidth = (widthScale * (x2 - x1)) / 2;
const halfHeight = (heightScale * (y2 - y1)) / 2;
const middleX = Math.round(widthScale * x1 + halfWidth);
const middleY = Math.round(heightScale * y1 + halfHeight);
// zoom out 10%
const targetHalfSize = Math.floor(Math.max(halfWidth, halfHeight) * 1.1);
@@ -509,8 +513,8 @@ export class PersonService {
const newHalfSize = Math.min(
middleX - Math.max(0, middleX - targetHalfSize),
middleY - Math.max(0, middleY - targetHalfSize),
Math.min(imageWidth - 1, middleX + targetHalfSize) - middleX,
Math.min(imageHeight - 1, middleY + targetHalfSize) - middleY,
Math.min(asset.exifInfo.exifImageWidth - 1, middleX + targetHalfSize) - middleX,
Math.min(asset.exifInfo.exifImageHeight - 1, middleY + targetHalfSize) - middleY,
);
const cropOptions: CropOptions = {
@@ -520,15 +524,15 @@ export class PersonService {
height: newHalfSize * 2,
};
const croppedOutput = await this.mediaRepository.crop(asset.resizePath, cropOptions);
const thumbnailOptions = {
format: 'jpeg',
size: FACE_THUMBNAIL_SIZE,
colorspace: thumbnail.colorspace,
quality: thumbnail.quality,
crop: cropOptions,
} as const;
await this.mediaRepository.resize(croppedOutput, thumbnailPath, thumbnailOptions);
await this.mediaRepository.generateThumbnail(asset.originalPath, thumbnailPath, thumbnailOptions);
await this.repository.update({ id: person.id, thumbnailPath });
return true;
@@ -3,11 +3,19 @@ import { Writable } from 'node:stream';
export const IMediaRepository = 'IMediaRepository';
export interface ResizeOptions {
export interface CropOptions {
top: number;
left: number;
width: number;
height: number;
}
export interface ThumbnailOptions {
size: number;
format: 'webp' | 'jpeg';
colorspace: string;
quality: number;
crop?: CropOptions;
}
export interface VideoStreamInfo {
@@ -40,13 +48,6 @@ export interface VideoInfo {
audioStreams: AudioStreamInfo[];
}
export interface CropOptions {
top: number;
left: number;
width: number;
height: number;
}
export interface TranscodeOptions {
inputOptions: string[];
outputOptions: string[];
@@ -70,8 +71,7 @@ export interface VideoCodecHWConfig extends VideoCodecSWConfig {
export interface IMediaRepository {
// image
resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>;
crop(input: string, options: CropOptions): Promise<Buffer>;
generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise<void>;
generateThumbhash(imagePath: string): Promise<Buffer>;
// video
@@ -1,11 +1,4 @@
import {
CropOptions,
IMediaRepository,
ResizeOptions,
TranscodeOptions,
VideoInfo,
handlePromiseError,
} from '@app/domain';
import { IMediaRepository, ThumbnailOptions, TranscodeOptions, VideoInfo, handlePromiseError } from '@app/domain';
import { Colorspace } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
@@ -21,21 +14,16 @@ sharp.cache({ files: 0 });
export class MediaRepository implements IMediaRepository {
private logger = new ImmichLogger(MediaRepository.name);
crop(input: string | Buffer, options: CropOptions): Promise<Buffer> {
return sharp(input, { failOn: 'none' })
.pipelineColorspace('rgb16')
.extract({
left: options.left,
top: options.top,
width: options.width,
height: options.height,
})
.toBuffer();
}
async generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise<void> {
const pipeline = sharp(input, { failOn: 'none' }).pipelineColorspace(
options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16',
);
async resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void> {
await sharp(input, { failOn: 'none' })
.pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16')
if (options.crop) {
pipeline.extract(options.crop);
}
await pipeline
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
.rotate()
.withIccProfile(options.colorspace)
+4
View File
@@ -165,6 +165,8 @@ export const assetStub = {
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 1000,
exifImageWidth: 1000,
} as ExifEntity,
stack: assetStackStub('stack-1', [
{ id: 'primary-asset-id' } as AssetEntity,
@@ -210,6 +212,8 @@ export const assetStub = {
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
exifImageWidth: 2160,
} as ExifEntity,
}),
@@ -2,9 +2,8 @@ import { IMediaRepository } from '@app/domain';
export const newMediaRepositoryMock = (): jest.Mocked<IMediaRepository> => {
return {
generateThumbnail: jest.fn(),
generateThumbhash: jest.fn(),
resize: jest.fn(),
crop: jest.fn(),
probe: jest.fn(),
transcode: jest.fn(),
};