1
0
mirror of https://github.com/immich-app/immich.git synced 2025-01-14 15:45:55 +02:00

feat(server): generate all thumbnails for an asset in one job (#13012)

* wip

cleanup

add success logs, rename method

do thumbhash too

fixes

fix tests

handle `notify`

wip refactor

refactor

* update tests

* update sql

* pr feedback

* remove unused code

* formatting
This commit is contained in:
Mert 2024-09-28 13:47:24 -04:00 committed by GitHub
parent 995f0fda47
commit 2bcd27e166
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 574 additions and 542 deletions

View File

@ -20,7 +20,7 @@ import {
VideoContainer, VideoContainer,
} from 'src/enum'; } from 'src/enum';
import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface';
import { ImageOutputConfig } from 'src/interfaces/media.interface'; import { ImageOptions } from 'src/interfaces/media.interface';
export interface SystemConfig { export interface SystemConfig {
ffmpeg: { ffmpeg: {
@ -110,8 +110,8 @@ export interface SystemConfig {
template: string; template: string;
}; };
image: { image: {
thumbnail: ImageOutputConfig; thumbnail: ImageOptions;
preview: ImageOutputConfig; preview: ImageOptions;
colorspace: Colorspace; colorspace: Colorspace;
extractEmbedded: boolean; extractEmbedded: boolean;
}; };

View File

@ -492,7 +492,7 @@ class SystemConfigGeneratedImageDto {
size!: number; size!: number;
} }
class SystemConfigImageDto { export class SystemConfigImageDto {
@Type(() => SystemConfigGeneratedImageDto) @Type(() => SystemConfigGeneratedImageDto)
@ValidateNested() @ValidateNested()
@IsObject() @IsObject()

View File

@ -141,6 +141,12 @@ export interface AssetUpdateDuplicateOptions {
duplicateIds: string[]; duplicateIds: string[];
} }
export interface UpsertFileOptions {
assetId: string;
type: AssetFileType;
path: string;
}
export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffline'>; export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffline'>;
export const IAssetRepository = 'IAssetRepository'; export const IAssetRepository = 'IAssetRepository';
@ -194,5 +200,6 @@ export interface IAssetRepository {
getDuplicates(options: AssetBuilderOptions): Promise<AssetEntity[]>; getDuplicates(options: AssetBuilderOptions): Promise<AssetEntity[]>;
getAllForUserFullSync(options: AssetFullSyncOptions): Promise<AssetEntity[]>; getAllForUserFullSync(options: AssetFullSyncOptions): Promise<AssetEntity[]>;
getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise<AssetEntity[]>; getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise<AssetEntity[]>;
upsertFile(options: { assetId: string; type: AssetFileType; path: string }): Promise<void>; upsertFile(file: UpsertFileOptions): Promise<void>;
upsertFiles(files: UpsertFileOptions[]): Promise<void>;
} }

View File

@ -37,9 +37,7 @@ export enum JobName {
// thumbnails // thumbnails
QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails', QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails',
GENERATE_PREVIEW = 'generate-preview', GENERATE_THUMBNAILS = 'generate-thumbnails',
GENERATE_THUMBNAIL = 'generate-thumbnail',
GENERATE_THUMBHASH = 'generate-thumbhash',
GENERATE_PERSON_THUMBNAIL = 'generate-person-thumbnail', GENERATE_PERSON_THUMBNAIL = 'generate-person-thumbnail',
// metadata // metadata
@ -212,9 +210,7 @@ export type JobItem =
// Thumbnails // Thumbnails
| { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob } | { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob }
| { name: JobName.GENERATE_PREVIEW; data: IEntityJob } | { name: JobName.GENERATE_THUMBNAILS; data: IEntityJob }
| { name: JobName.GENERATE_THUMBNAIL; data: IEntityJob }
| { name: JobName.GENERATE_THUMBHASH; data: IEntityJob }
// User // User
| { name: JobName.USER_DELETE_CHECK; data?: IBaseJob } | { name: JobName.USER_DELETE_CHECK; data?: IBaseJob }

View File

@ -10,16 +10,44 @@ export interface CropOptions {
height: number; height: number;
} }
export interface ImageOutputConfig { export interface ImageOptions {
format: ImageFormat; format: ImageFormat;
quality: number; quality: number;
size: number; size: number;
} }
export interface ThumbnailOptions extends ImageOutputConfig { export interface RawImageInfo {
width: number;
height: number;
channels: 1 | 2 | 3 | 4;
}
interface DecodeImageOptions {
colorspace: string; colorspace: string;
crop?: CropOptions; crop?: CropOptions;
processInvalidImages: boolean; processInvalidImages: boolean;
raw?: RawImageInfo;
}
export interface DecodeToBufferOptions extends DecodeImageOptions {
size: number;
}
export type GenerateThumbnailOptions = ImageOptions & DecodeImageOptions;
export type GenerateThumbnailFromBufferOptions = GenerateThumbnailOptions & { raw: RawImageInfo };
export type GenerateThumbhashOptions = DecodeImageOptions;
export type GenerateThumbhashFromBufferOptions = GenerateThumbhashOptions & { raw: RawImageInfo };
export interface GenerateThumbnailsOptions {
colorspace: string;
crop?: CropOptions;
preview?: ImageOptions;
processInvalidImages: boolean;
thumbhash?: boolean;
thumbnail?: ImageOptions;
} }
export interface VideoStreamInfo { export interface VideoStreamInfo {
@ -78,6 +106,11 @@ export interface BitrateDistribution {
unit: string; unit: string;
} }
export interface ImageBuffer {
data: Buffer;
info: RawImageInfo;
}
export interface VideoCodecSWConfig { export interface VideoCodecSWConfig {
getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand; getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand;
} }
@ -93,8 +126,11 @@ export interface ProbeOptions {
export interface IMediaRepository { export interface IMediaRepository {
// image // image
extract(input: string, output: string): Promise<boolean>; extract(input: string, output: string): Promise<boolean>;
generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise<void>; decodeImage(input: string, options: DecodeToBufferOptions): Promise<ImageBuffer>;
generateThumbhash(imagePath: string): Promise<Buffer>; generateThumbnail(input: string, options: GenerateThumbnailOptions, outputFile: string): Promise<void>;
generateThumbnail(input: Buffer, options: GenerateThumbnailFromBufferOptions, outputFile: string): Promise<void>;
generateThumbhash(input: string, options: GenerateThumbhashOptions): Promise<Buffer>;
generateThumbhash(input: Buffer, options: GenerateThumbhashFromBufferOptions): Promise<Buffer>;
getImageDimensions(input: string): Promise<ImageDimensions>; getImageDimensions(input: string): Promise<ImageDimensions>;
// video // video

View File

@ -1132,3 +1132,27 @@ RETURNING
"id", "id",
"createdAt", "createdAt",
"updatedAt" "updatedAt"
-- AssetRepository.upsertFiles
INSERT INTO
"asset_files" (
"id",
"assetId",
"createdAt",
"updatedAt",
"type",
"path"
)
VALUES
(DEFAULT, $1, DEFAULT, DEFAULT, $2, $3)
ON CONFLICT ("assetId", "type") DO
UPDATE
SET
"assetId" = EXCLUDED."assetId",
"type" = EXCLUDED."type",
"path" = EXCLUDED."path",
"updatedAt" = DEFAULT
RETURNING
"id",
"createdAt",
"updatedAt"

View File

@ -801,7 +801,12 @@ export class AssetRepository implements IAssetRepository {
} }
@GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] })
async upsertFile({ assetId, type, path }: { assetId: string; type: AssetFileType; path: string }): Promise<void> { async upsertFile(file: { assetId: string; type: AssetFileType; path: string }): Promise<void> {
await this.fileRepository.upsert({ assetId, type, path }, { conflictPaths: ['assetId', 'type'] }); await this.fileRepository.upsert(file, { conflictPaths: ['assetId', 'type'] });
}
@GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] })
async upsertFiles(files: { assetId: string; type: AssetFileType; path: string }[]): Promise<void> {
await this.fileRepository.upsert(files, { conflictPaths: ['assetId', 'type'] });
} }
} }

View File

@ -36,9 +36,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
// thumbnails // thumbnails
[JobName.QUEUE_GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION, [JobName.QUEUE_GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_PREVIEW]: QueueName.THUMBNAIL_GENERATION, [JobName.GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_THUMBHASH]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, [JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
// tags // tags

View File

@ -8,10 +8,12 @@ import sharp from 'sharp';
import { Colorspace, LogLevel } from 'src/enum'; import { Colorspace, LogLevel } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { import {
DecodeToBufferOptions,
GenerateThumbhashOptions,
GenerateThumbnailOptions,
IMediaRepository, IMediaRepository,
ImageDimensions, ImageDimensions,
ProbeOptions, ProbeOptions,
ThumbnailOptions,
TranscodeCommand, TranscodeCommand,
VideoInfo, VideoInfo,
} from 'src/interfaces/media.interface'; } from 'src/interfaces/media.interface';
@ -57,19 +59,12 @@ export class MediaRepository implements IMediaRepository {
return true; return true;
} }
async generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise<void> { decodeImage(input: string, options: DecodeToBufferOptions) {
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true });
const pipeline = sharp(input, { failOn: options.processInvalidImages ? 'none' : 'error', limitInputPixels: false }) }
.pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16')
.rotate();
if (options.crop) { async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise<void> {
pipeline.extract(options.crop); await this.getImageDecodingPipeline(input, options)
}
await pipeline
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
.withIccProfile(options.colorspace)
.toFormat(options.format, { .toFormat(options.format, {
quality: options.quality, quality: options.quality,
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
@ -78,6 +73,40 @@ export class MediaRepository implements IMediaRepository {
.toFile(output); .toFile(output);
} }
private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
let pipeline = sharp(input, {
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes
failOn: options.processInvalidImages ? 'none' : 'error',
limitInputPixels: false,
raw: options.raw,
});
if (!options.raw) {
pipeline = pipeline
.pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16')
.withIccProfile(options.colorspace)
.rotate();
}
if (options.crop) {
pipeline = pipeline.extract(options.crop);
}
return pipeline.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true });
}
async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise<Buffer> {
const [{ rgbaToThumbHash }, { data, info }] = await Promise.all([
import('thumbhash'),
sharp(input, options)
.resize(100, 100, { fit: 'inside', withoutEnlargement: true })
.raw()
.ensureAlpha()
.toBuffer({ resolveWithObject: true }),
]);
return Buffer.from(rgbaToThumbHash(info.width, info.height, data));
}
async probe(input: string, options?: ProbeOptions): Promise<VideoInfo> { async probe(input: string, options?: ProbeOptions): Promise<VideoInfo> {
const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817 const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817
return { return {
@ -150,19 +179,6 @@ export class MediaRepository implements IMediaRepository {
}); });
} }
async generateThumbhash(imagePath: string): Promise<Buffer> {
const maxSize = 100;
const { data, info } = await sharp(imagePath)
.resize(maxSize, maxSize, { fit: 'inside', withoutEnlargement: true })
.raw()
.ensureAlpha()
.toBuffer({ resolveWithObject: true });
const thumbhash = await import('thumbhash');
return Buffer.from(thumbhash.rgbaToThumbHash(info.width, info.height, data));
}
async getImageDimensions(input: string): Promise<ImageDimensions> { async getImageDimensions(input: string): Promise<ImageDimensions> {
const { width = 0, height = 0 } = await sharp(input).metadata(); const { width = 0, height = 0 } = await sharp(input).metadata();
return { width, height }; return { width, height };

View File

@ -395,7 +395,7 @@ describe(AssetService.name, () => {
it('should run the refresh thumbnails job', async () => { it('should run the refresh thumbnails job', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL });
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1' } }]); expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }]);
}); });
it('should run the transcode video', async () => { it('should run the transcode video', async () => {

View File

@ -322,7 +322,7 @@ export class AssetService {
} }
case AssetJobName.REGENERATE_THUMBNAIL: { case AssetJobName.REGENERATE_THUMBNAIL: {
jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id } }); jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id } });
break; break;
} }

View File

@ -288,7 +288,7 @@ describe(JobService.name, () => {
}, },
{ {
item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1', source: 'upload' } }, item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1', source: 'upload' } },
jobs: [JobName.GENERATE_PREVIEW], jobs: [JobName.GENERATE_THUMBNAILS],
}, },
{ {
item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1' } }, item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1' } },
@ -299,28 +299,16 @@ describe(JobService.name, () => {
jobs: [], jobs: [],
}, },
{ {
item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1' } }, item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } },
jobs: [JobName.GENERATE_THUMBNAIL, JobName.GENERATE_THUMBHASH], jobs: [],
}, },
{ {
item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1', source: 'upload' } }, item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1', source: 'upload' } },
jobs: [ jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION],
JobName.GENERATE_THUMBNAIL,
JobName.GENERATE_THUMBHASH,
JobName.SMART_SEARCH,
JobName.FACE_DETECTION,
JobName.VIDEO_CONVERSION,
],
}, },
{ {
item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-live-image', source: 'upload' } }, item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-live-image', source: 'upload' } },
jobs: [ jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION],
JobName.GENERATE_THUMBNAIL,
JobName.GENERATE_THUMBHASH,
JobName.SMART_SEARCH,
JobName.FACE_DETECTION,
JobName.VIDEO_CONVERSION,
],
}, },
{ {
item: { name: JobName.SMART_SEARCH, data: { id: 'asset-1' } }, item: { name: JobName.SMART_SEARCH, data: { id: 'asset-1' } },
@ -338,11 +326,11 @@ describe(JobService.name, () => {
for (const { item, jobs } of tests) { for (const { item, jobs } of tests) {
it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => { it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => {
if (item.name === JobName.GENERATE_PREVIEW && item.data.source === 'upload') { if (item.name === JobName.GENERATE_THUMBNAILS && item.data.source === 'upload') {
if (item.data.id === 'asset-live-image') { if (item.data.id === 'asset-live-image') {
assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]);
} else { } else {
assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]);
} }
} }
@ -361,7 +349,7 @@ describe(JobService.name, () => {
} }
}); });
it(`should not queue any jobs when ${item.name} finishes with 'false'`, async () => { it(`should not queue any jobs when ${item.name} fails`, async () => {
await sut.init(makeMockHandlers(JobStatus.FAILED)); await sut.init(makeMockHandlers(JobStatus.FAILED));
await jobMock.addHandler.mock.calls[0][2](item); await jobMock.addHandler.mock.calls[0][2](item);

View File

@ -281,7 +281,7 @@ export class JobService {
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
if (item.data.source === 'upload' || item.data.source === 'copy') { if (item.data.source === 'upload' || item.data.source === 'copy') {
await this.jobRepository.queue({ name: JobName.GENERATE_PREVIEW, data: item.data }); await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: item.data });
} }
break; break;
} }
@ -295,40 +295,33 @@ export class JobService {
break; break;
} }
case JobName.GENERATE_PREVIEW: { case JobName.GENERATE_THUMBNAILS: {
const jobs: JobItem[] = [ if (!item.data.notify && item.data.source !== 'upload') {
{ name: JobName.GENERATE_THUMBNAIL, data: item.data },
{ name: JobName.GENERATE_THUMBHASH, data: item.data },
];
if (item.data.source === 'upload') {
jobs.push({ name: JobName.SMART_SEARCH, data: item.data }, { name: JobName.FACE_DETECTION, data: item.data });
const [asset] = await this.assetRepository.getByIds([item.data.id]);
if (asset) {
if (asset.type === AssetType.VIDEO) {
jobs.push({ name: JobName.VIDEO_CONVERSION, data: item.data });
} else if (asset.livePhotoVideoId) {
jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } });
}
}
}
await this.jobRepository.queueAll(jobs);
break;
}
case JobName.GENERATE_THUMBNAIL: {
if (!(item.data.notify || item.data.source === 'upload')) {
break; break;
} }
const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]); const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]);
if (!asset) {
this.logger.warn(`Could not find asset ${item.data.id} after generating thumbnails`);
break;
}
// Only live-photo motion part will be marked as not visible immediately on upload. Skip notifying clients const jobs: JobItem[] = [
if (asset && asset.isVisible) { { name: JobName.SMART_SEARCH, data: item.data },
{ name: JobName.FACE_DETECTION, data: item.data },
];
if (asset.type === AssetType.VIDEO) {
jobs.push({ name: JobName.VIDEO_CONVERSION, data: item.data });
} else if (asset.livePhotoVideoId) {
jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } });
}
await this.jobRepository.queueAll(jobs);
if (asset.isVisible) {
this.eventRepository.clientSend(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); this.eventRepository.clientSend(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
} }
break; break;
} }

View File

@ -15,7 +15,7 @@ import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interfac
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMediaRepository } from 'src/interfaces/media.interface'; import { IMediaRepository, RawImageInfo } from 'src/interfaces/media.interface';
import { IMoveRepository } from 'src/interfaces/move.interface'; import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
@ -94,7 +94,7 @@ describe(MediaService.name, () => {
expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(assetMock.getWithout).not.toHaveBeenCalled();
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.GENERATE_PREVIEW, name: JobName.GENERATE_THUMBNAILS,
data: { id: assetStub.image.id }, data: { id: assetStub.image.id },
}, },
]); ]);
@ -127,7 +127,7 @@ describe(MediaService.name, () => {
expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(assetMock.getWithout).not.toHaveBeenCalled();
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.GENERATE_PREVIEW, name: JobName.GENERATE_THUMBNAILS,
data: { id: assetStub.trashed.id }, data: { id: assetStub.trashed.id },
}, },
]); ]);
@ -152,7 +152,7 @@ describe(MediaService.name, () => {
expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(assetMock.getWithout).not.toHaveBeenCalled();
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.GENERATE_PREVIEW, name: JobName.GENERATE_THUMBNAILS,
data: { id: assetStub.archived.id }, data: { id: assetStub.archived.id },
}, },
]); ]);
@ -202,7 +202,7 @@ describe(MediaService.name, () => {
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.GENERATE_PREVIEW, name: JobName.GENERATE_THUMBNAILS,
data: { id: assetStub.image.id }, data: { id: assetStub.image.id },
}, },
]); ]);
@ -226,7 +226,7 @@ describe(MediaService.name, () => {
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.GENERATE_THUMBNAIL, name: JobName.GENERATE_THUMBNAILS,
data: { id: assetStub.image.id }, data: { id: assetStub.image.id },
}, },
]); ]);
@ -250,7 +250,7 @@ describe(MediaService.name, () => {
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.GENERATE_THUMBHASH, name: JobName.GENERATE_THUMBNAILS,
data: { id: assetStub.image.id }, data: { id: assetStub.image.id },
}, },
]); ]);
@ -259,10 +259,19 @@ describe(MediaService.name, () => {
}); });
}); });
describe('handleGeneratePreview', () => { describe('handleGenerateThumbnails', () => {
let rawBuffer: Buffer;
let rawInfo: RawImageInfo;
beforeEach(() => {
rawBuffer = Buffer.from('image data');
rawInfo = { width: 100, height: 100, channels: 3 };
mediaMock.decodeImage.mockResolvedValue({ data: rawBuffer, info: rawInfo });
});
it('should skip thumbnail generation if asset not found', async () => { it('should skip thumbnail generation if asset not found', async () => {
assetMock.getByIds.mockResolvedValue([]); await sut.handleGenerateThumbnails({ id: assetStub.image.id });
await sut.handleGeneratePreview({ id: assetStub.image.id });
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalledWith(); expect(assetMock.update).not.toHaveBeenCalledWith();
}); });
@ -270,80 +279,100 @@ describe(MediaService.name, () => {
it('should skip video thumbnail generation if no video stream', async () => { it('should skip video thumbnail generation if no video stream', async () => {
mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams); mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleGeneratePreview({ id: assetStub.image.id }); await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalledWith(); expect(assetMock.update).not.toHaveBeenCalledWith();
}); });
it('should skip invisible assets', async () => { it('should skip invisible assets', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
expect(await sut.handleGeneratePreview({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); expect(await sut.handleGenerateThumbnails({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED);
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalledWith(); expect(assetMock.update).not.toHaveBeenCalledWith();
}); });
it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => {
systemMock.get.mockResolvedValue({ image: { preview: { format } } });
assetMock.getByIds.mockResolvedValue([assetStub.image]);
const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`;
await sut.handleGeneratePreview({ id: assetStub.image.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith('/original/path.jpg', previewPath, {
size: 1440,
format,
quality: 80,
colorspace: Colorspace.SRGB,
processInvalidImages: false,
});
expect(assetMock.upsertFile).toHaveBeenCalledWith({
assetId: 'asset-id',
type: AssetFileType.PREVIEW,
path: previewPath,
});
});
it('should delete previous preview if different path', async () => { it('should delete previous preview if different path', async () => {
systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } });
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getById.mockResolvedValue(assetStub.image);
await sut.handleGeneratePreview({ id: assetStub.image.id }); await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg');
}); });
it('should generate a P3 thumbnail for a wide gamut image', async () => { it('should generate P3 thumbnails for a wide gamut image', async () => {
assetMock.getByIds.mockResolvedValue([ assetMock.getById.mockResolvedValue({
{ ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity }, ...assetStub.image,
]); exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity,
await sut.handleGeneratePreview({ id: assetStub.image.id }); });
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
'/original/path.jpg', expect(mediaMock.decodeImage).toHaveBeenCalledOnce();
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, {
{ colorspace: Colorspace.P3,
size: 1440, processInvalidImages: false,
format: ImageFormat.JPEG, size: 1440,
quality: 80,
colorspace: Colorspace.P3,
processInvalidImages: false,
},
);
expect(assetMock.upsertFile).toHaveBeenCalledWith({
assetId: 'asset-id',
type: AssetFileType.PREVIEW,
path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
}); });
expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2);
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
{
colorspace: Colorspace.P3,
format: ImageFormat.JPEG,
size: 1440,
quality: 80,
processInvalidImages: false,
raw: rawInfo,
},
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
);
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
{
colorspace: Colorspace.P3,
format: ImageFormat.WEBP,
size: 250,
quality: 80,
processInvalidImages: false,
raw: rawInfo,
},
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
);
expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce();
expect(mediaMock.generateThumbhash).toHaveBeenCalledWith(rawBuffer, {
colorspace: Colorspace.P3,
processInvalidImages: false,
raw: rawInfo,
});
expect(assetMock.upsertFiles).toHaveBeenCalledWith([
{
assetId: 'asset-id',
type: AssetFileType.PREVIEW,
path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
},
{
assetId: 'asset-id',
type: AssetFileType.THUMBNAIL,
path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
},
]);
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
}); });
it('should generate a thumbnail for a video', async () => { it('should generate a thumbnail for a video', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getById.mockResolvedValue(assetStub.video);
await sut.handleGeneratePreview({ id: assetStub.video.id }); await sut.handleGenerateThumbnails({ id: assetStub.video.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -361,17 +390,24 @@ describe(MediaService.name, () => {
twoPass: false, twoPass: false,
}), }),
); );
expect(assetMock.upsertFile).toHaveBeenCalledWith({ expect(assetMock.upsertFiles).toHaveBeenCalledWith([
assetId: 'asset-id', {
type: AssetFileType.PREVIEW, assetId: 'asset-id',
path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', type: AssetFileType.PREVIEW,
}); path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
},
{
assetId: 'asset-id',
type: AssetFileType.THUMBNAIL,
path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
},
]);
}); });
it('should tonemap thumbnail for hdr video', async () => { it('should tonemap thumbnail for hdr video', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getById.mockResolvedValue(assetStub.video);
await sut.handleGeneratePreview({ id: assetStub.video.id }); await sut.handleGenerateThumbnails({ id: assetStub.video.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -389,11 +425,18 @@ describe(MediaService.name, () => {
twoPass: false, twoPass: false,
}), }),
); );
expect(assetMock.upsertFile).toHaveBeenCalledWith({ expect(assetMock.upsertFiles).toHaveBeenCalledWith([
assetId: 'asset-id', {
type: AssetFileType.PREVIEW, assetId: 'asset-id',
path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', type: AssetFileType.PREVIEW,
}); path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
},
{
assetId: 'asset-id',
type: AssetFileType.THUMBNAIL,
path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
},
]);
}); });
it('should always generate video thumbnail in one pass', async () => { it('should always generate video thumbnail in one pass', async () => {
@ -401,8 +444,8 @@ describe(MediaService.name, () => {
systemMock.get.mockResolvedValue({ systemMock.get.mockResolvedValue({
ffmpeg: { twoPass: true, maxBitrate: '5000k' }, ffmpeg: { twoPass: true, maxBitrate: '5000k' },
}); });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getById.mockResolvedValue(assetStub.video);
await sut.handleGeneratePreview({ id: assetStub.video.id }); await sut.handleGenerateThumbnails({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
@ -424,8 +467,8 @@ describe(MediaService.name, () => {
it('should use scaling divisible by 2 even when using quick sync', async () => { it('should use scaling divisible by 2 even when using quick sync', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getById.mockResolvedValue(assetStub.video);
await sut.handleGeneratePreview({ id: assetStub.video.id }); await sut.handleGenerateThumbnails({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/original/path.ext',
@ -438,233 +481,207 @@ describe(MediaService.name, () => {
); );
}); });
it('should run successfully', async () => { it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => {
assetMock.getByIds.mockResolvedValue([assetStub.image]); systemMock.get.mockResolvedValue({ image: { preview: { format } } });
await sut.handleGeneratePreview({ id: assetStub.image.id }); assetMock.getById.mockResolvedValue(assetStub.image);
}); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
}); mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer);
const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`;
const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.webp`;
describe('handleGenerateThumbnail', () => { await sut.handleGenerateThumbnails({ id: assetStub.image.id });
it('should skip thumbnail generation if asset not found', async () => {
assetMock.getByIds.mockResolvedValue([]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalledWith();
});
it('should skip invisible assets', async () => { expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); expect(mediaMock.decodeImage).toHaveBeenCalledOnce();
expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, {
colorspace: Colorspace.SRGB,
processInvalidImages: false,
size: 1440,
});
expect(await sut.handleGenerateThumbnail({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2);
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); rawBuffer,
expect(assetMock.update).not.toHaveBeenCalledWith(); {
});
it.each(Object.values(ImageFormat))(
'should generate a %s thumbnail for an image when specified',
async (format) => {
systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } });
assetMock.getByIds.mockResolvedValue([assetStub.image]);
const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`;
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith('/original/path.jpg', thumbnailPath, {
size: 250,
format,
quality: 80,
colorspace: Colorspace.SRGB, colorspace: Colorspace.SRGB,
format,
size: 1440,
quality: 80,
processInvalidImages: false, processInvalidImages: false,
}); raw: rawInfo,
expect(assetMock.upsertFile).toHaveBeenCalledWith({ },
assetId: 'asset-id', previewPath,
type: AssetFileType.THUMBNAIL, );
path: thumbnailPath, expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
}); rawBuffer,
}, {
); colorspace: Colorspace.SRGB,
format: ImageFormat.WEBP,
size: 250,
quality: 80,
processInvalidImages: false,
raw: rawInfo,
},
thumbnailPath,
);
});
it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => {
systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } });
assetMock.getById.mockResolvedValue(assetStub.image);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer);
const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.jpeg`;
const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`;
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
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).toHaveBeenCalledWith(
rawBuffer,
{
colorspace: Colorspace.SRGB,
format: ImageFormat.JPEG,
size: 1440,
quality: 80,
processInvalidImages: false,
raw: rawInfo,
},
previewPath,
);
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
{
colorspace: Colorspace.SRGB,
format,
size: 250,
quality: 80,
processInvalidImages: false,
raw: rawInfo,
},
thumbnailPath,
);
});
it('should delete previous thumbnail if different path', async () => { it('should delete previous thumbnail if different path', async () => {
systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } });
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getById.mockResolvedValue(assetStub.image);
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext');
}); });
});
it('should generate a P3 thumbnail for a wide gamut image', async () => { it('should extract embedded image if enabled and available', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); mediaMock.extract.mockResolvedValue(true);
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } });
assetMock.getById.mockResolvedValue(assetStub.imageDng);
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
assetStub.imageDng.originalPath, const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString();
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', expect(mediaMock.decodeImage).toHaveBeenCalledOnce();
{ expect(mediaMock.decodeImage).toHaveBeenCalledWith(extractedPath, {
format: ImageFormat.WEBP,
size: 250,
quality: 80,
colorspace: Colorspace.P3, colorspace: Colorspace.P3,
processInvalidImages: false, processInvalidImages: false,
}, size: 1440,
); });
expect(assetMock.upsertFile).toHaveBeenCalledWith({ expect(extractedPath?.endsWith('.tmp')).toBe(true);
assetId: 'asset-id', expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath);
type: AssetFileType.THUMBNAIL,
path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
}); });
});
it('should extract embedded image if enabled and available', async () => { it('should resize original image if embedded image is too small', async () => {
mediaMock.extract.mockResolvedValue(true); mediaMock.extract.mockResolvedValue(true);
mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 });
systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } });
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); assetMock.getById.mockResolvedValue(assetStub.imageDng);
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); await sut.handleGenerateThumbnails({ id: assetStub.image.id });
const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); expect(mediaMock.decodeImage).toHaveBeenCalledOnce();
expect(mediaMock.generateThumbnail.mock.calls).toEqual([ expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, {
[ colorspace: Colorspace.P3,
extractedPath, processInvalidImages: false,
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', size: 1440,
{ });
format: ImageFormat.WEBP, const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString();
size: 250, expect(extractedPath?.endsWith('.tmp')).toBe(true);
quality: 80, expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath);
colorspace: Colorspace.P3, });
processInvalidImages: false,
},
],
]);
expect(extractedPath?.endsWith('.tmp')).toBe(true);
expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath);
});
it('should resize original image if embedded image is too small', async () => { it('should resize original image if embedded image not found', async () => {
mediaMock.extract.mockResolvedValue(true); systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } });
mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); assetMock.getById.mockResolvedValue(assetStub.imageDng);
systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } });
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mediaMock.generateThumbnail.mock.calls).toEqual([ expect(mediaMock.decodeImage).toHaveBeenCalledOnce();
[ expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, {
colorspace: Colorspace.P3,
processInvalidImages: false,
size: 1440,
});
expect(mediaMock.getImageDimensions).not.toHaveBeenCalled();
});
it('should resize original image if embedded image extraction is not enabled', async () => {
systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } });
assetMock.getById.mockResolvedValue(assetStub.imageDng);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mediaMock.extract).not.toHaveBeenCalled();
expect(mediaMock.decodeImage).toHaveBeenCalledOnce();
expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, {
colorspace: Colorspace.P3,
processInvalidImages: false,
size: 1440,
});
expect(mediaMock.getImageDimensions).not.toHaveBeenCalled();
});
it('should process invalid images if enabled', async () => {
vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true');
assetMock.getById.mockResolvedValue(assetStub.imageDng);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
expect(mediaMock.decodeImage).toHaveBeenCalledOnce();
expect(mediaMock.decodeImage).toHaveBeenCalledWith(
assetStub.imageDng.originalPath, assetStub.imageDng.originalPath,
expect.objectContaining({ processInvalidImages: true }),
);
expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2);
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.objectContaining({ processInvalidImages: true }),
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
);
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.objectContaining({ processInvalidImages: true }),
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
{ );
format: ImageFormat.WEBP,
size: 250,
quality: 80,
colorspace: Colorspace.P3,
processInvalidImages: false,
},
],
]);
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 () => { expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce();
systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); expect(mediaMock.generateThumbhash).toHaveBeenCalledWith(
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); rawBuffer,
expect.objectContaining({ processInvalidImages: true }),
);
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); expect(mediaMock.getImageDimensions).not.toHaveBeenCalled();
vi.unstubAllEnvs();
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
assetStub.imageDng.originalPath,
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
{
format: ImageFormat.WEBP,
size: 250,
quality: 80,
colorspace: Colorspace.P3,
processInvalidImages: false,
},
);
expect(mediaMock.getImageDimensions).not.toHaveBeenCalled();
});
it('should resize original image if embedded image extraction is not enabled', async () => {
systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } });
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
expect(mediaMock.extract).not.toHaveBeenCalled();
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
assetStub.imageDng.originalPath,
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
{
format: ImageFormat.WEBP,
size: 250,
quality: 80,
colorspace: Colorspace.P3,
processInvalidImages: false,
},
);
expect(mediaMock.getImageDimensions).not.toHaveBeenCalled();
});
it('should process invalid images if enabled', async () => {
vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true');
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
assetStub.imageDng.originalPath,
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
{
format: ImageFormat.WEBP,
size: 250,
quality: 80,
colorspace: Colorspace.P3,
processInvalidImages: true,
},
);
expect(mediaMock.getImageDimensions).not.toHaveBeenCalled();
vi.unstubAllEnvs();
});
describe('handleGenerateThumbhash', () => {
it('should skip thumbhash generation if asset not found', async () => {
assetMock.getByIds.mockResolvedValue([]);
await sut.handleGenerateThumbhash({ id: assetStub.image.id });
expect(mediaMock.generateThumbhash).not.toHaveBeenCalled();
});
it('should skip thumbhash generation if resize path is missing', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]);
await sut.handleGenerateThumbhash({ id: assetStub.noResizePath.id });
expect(mediaMock.generateThumbhash).not.toHaveBeenCalled();
});
it('should skip invisible assets', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]);
expect(await sut.handleGenerateThumbhash({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED);
expect(mediaMock.generateThumbhash).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalledWith();
});
it('should generate a thumbhash', async () => {
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
assetMock.getByIds.mockResolvedValue([assetStub.image]);
mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer);
await sut.handleGenerateThumbhash({ id: assetStub.image.id });
expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg');
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
}); });
}); });

View File

@ -1,6 +1,7 @@
import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { GeneratedImageType, StorageCore } from 'src/cores/storage.core';
import { StorageCore } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
@ -18,7 +19,7 @@ import {
VideoCodec, VideoCodec,
VideoContainer, VideoContainer,
} from 'src/enum'; } from 'src/enum';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { import {
IBaseJob, IBaseJob,
@ -95,18 +96,10 @@ export class MediaService {
for (const asset of assets) { for (const asset of assets) {
const { previewFile, thumbnailFile } = getAssetFiles(asset.files); const { previewFile, thumbnailFile } = getAssetFiles(asset.files);
if (!previewFile || force) { if (!previewFile || !thumbnailFile || !asset.thumbhash || force) {
jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id: asset.id } }); jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } });
continue; continue;
} }
if (!thumbnailFile) {
jobs.push({ name: JobName.GENERATE_THUMBNAIL, data: { id: asset.id } });
}
if (!asset.thumbhash) {
jobs.push({ name: JobName.GENERATE_THUMBHASH, data: { id: asset.id } });
}
} }
await this.jobRepository.queueAll(jobs); await this.jobRepository.queueAll(jobs);
@ -181,141 +174,127 @@ export class MediaService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleGeneratePreview({ id }: IEntityJob): Promise<JobStatus> { async handleGenerateThumbnails({ id }: IEntityJob): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true }); const asset = await this.assetRepository.getById(id, { exifInfo: true, files: true });
if (!asset) { if (!asset) {
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`);
return JobStatus.FAILED; return JobStatus.FAILED;
} }
if (!asset.isVisible) { if (!asset.isVisible) {
this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`);
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW); let generated: { previewPath: string; thumbnailPath: string; thumbhash: Buffer };
if (!previewPath) { if (asset.type === AssetType.IMAGE) {
generated = await this.generateImageThumbnails(asset);
} else if (asset.type === AssetType.VIDEO) {
generated = await this.generateVideoThumbnails(asset);
} else {
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const { previewFile } = getAssetFiles(asset.files); const { previewFile, thumbnailFile } = getAssetFiles(asset.files);
if (previewFile && previewFile.path !== previewPath) { const toUpsert: UpsertFileOptions[] = [];
if (previewFile?.path !== generated.previewPath) {
toUpsert.push({ assetId: asset.id, path: generated.previewPath, type: AssetFileType.PREVIEW });
}
if (thumbnailFile?.path !== generated.thumbnailPath) {
toUpsert.push({ assetId: asset.id, path: generated.thumbnailPath, type: AssetFileType.THUMBNAIL });
}
if (toUpsert.length > 0) {
await this.assetRepository.upsertFiles(toUpsert);
}
const pathsToDelete = [];
if (previewFile && previewFile.path !== generated.previewPath) {
this.logger.debug(`Deleting old preview for asset ${asset.id}`); this.logger.debug(`Deleting old preview for asset ${asset.id}`);
await this.storageRepository.unlink(previewFile.path); pathsToDelete.push(previewFile.path);
} }
await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.PREVIEW, path: previewPath }); if (thumbnailFile && thumbnailFile.path !== generated.thumbnailPath) {
await this.assetRepository.update({ id: asset.id, updatedAt: new Date() });
await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date() });
return JobStatus.SUCCESS;
}
private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType) {
const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true });
const { size, format, quality } = image[type];
const path = StorageCore.getImagePath(asset, type, format);
this.storageCore.ensureFolders(path);
switch (asset.type) {
case AssetType.IMAGE: {
const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath);
const extractedPath = StorageCore.getTempPathInDir(dirname(path));
const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath));
try {
const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size));
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace;
const imageOptions = {
format,
size,
colorspace,
quality,
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
};
const outputPath = useExtracted ? extractedPath : asset.originalPath;
await this.mediaRepository.generateThumbnail(outputPath, path, imageOptions);
} finally {
if (didExtract) {
await this.storageRepository.unlink(extractedPath);
}
}
break;
}
case AssetType.VIDEO: {
const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
const mainVideoStream = this.getMainStream(videoStreams);
if (!mainVideoStream) {
this.logger.warn(`Skipped thumbnail generation for asset ${asset.id}: no video streams found`);
return;
}
const mainAudioStream = this.getMainStream(audioStreams);
const config = ThumbnailConfig.create({ ...ffmpeg, targetResolution: size.toString() });
const options = config.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(asset.originalPath, path, options);
break;
}
default: {
throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`);
}
}
const assetLabel = asset.isExternal ? asset.originalPath : asset.id;
this.logger.log(
`Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} ${type} for asset ${assetLabel}`,
);
return path;
}
async handleGenerateThumbnail({ id }: IEntityJob): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true });
if (!asset) {
return JobStatus.FAILED;
}
if (!asset.isVisible) {
return JobStatus.SKIPPED;
}
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL);
if (!thumbnailPath) {
return JobStatus.SKIPPED;
}
const { thumbnailFile } = getAssetFiles(asset.files);
if (thumbnailFile && thumbnailFile.path !== thumbnailPath) {
this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`); this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`);
await this.storageRepository.unlink(thumbnailFile.path); pathsToDelete.push(thumbnailFile.path);
} }
await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.THUMBNAIL, path: thumbnailPath }); if (pathsToDelete.length > 0) {
await this.assetRepository.update({ id: asset.id, updatedAt: new Date() }); await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path)));
await this.assetRepository.upsertJobStatus({ assetId: asset.id, thumbnailAt: new Date() }); }
if (asset.thumbhash != generated.thumbhash) {
await this.assetRepository.update({ id: asset.id, thumbhash: generated.thumbhash });
}
await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date(), thumbnailAt: new Date() });
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleGenerateThumbhash({ id }: IEntityJob): Promise<JobStatus> { private async generateImageThumbnails(asset: AssetEntity) {
const [asset] = await this.assetRepository.getByIds([id], { files: true }); const { image } = await this.configCore.getConfig({ withCache: true });
if (!asset) { const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format);
return JobStatus.FAILED; 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));
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 decodeOptions = { colorspace, processInvalidImages, size: image.preview.size };
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),
]);
return { previewPath, thumbnailPath, thumbhash: outputs[2] };
} finally {
if (didExtract) {
await this.storageRepository.unlink(extractedPath);
}
} }
}
if (!asset.isVisible) { private async generateVideoThumbnails(asset: AssetEntity) {
return JobStatus.SKIPPED; const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true });
const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format);
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
this.storageCore.ensureFolders(previewPath);
const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
const mainVideoStream = this.getMainStream(videoStreams);
if (!mainVideoStream) {
throw new Error(`No video streams found for asset ${asset.id}`);
} }
const mainAudioStream = this.getMainStream(audioStreams);
const { previewFile } = getAssetFiles(asset.files); const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() });
if (!previewFile) { const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() });
return JobStatus.FAILED;
}
const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path); const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
await this.assetRepository.update({ id: asset.id, thumbhash }); const thumbnailOptions = thumbnailConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions);
await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions);
return JobStatus.SUCCESS; const thumbhash = await this.mediaRepository.generateThumbhash(previewPath, {
colorspace: image.colorspace,
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
});
return { previewPath, thumbnailPath, thumbhash };
} }
async handleQueueVideoConversion(job: IBaseJob): Promise<JobStatus> { async handleQueueVideoConversion(job: IBaseJob): Promise<JobStatus> {

View File

@ -68,9 +68,7 @@ export class MicroservicesService {
[JobName.MIGRATE_ASSET]: (data) => this.mediaService.handleAssetMigration(data), [JobName.MIGRATE_ASSET]: (data) => this.mediaService.handleAssetMigration(data),
[JobName.MIGRATE_PERSON]: (data) => this.personService.handlePersonMigration(data), [JobName.MIGRATE_PERSON]: (data) => this.personService.handlePersonMigration(data),
[JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data), [JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data),
[JobName.GENERATE_PREVIEW]: (data) => this.mediaService.handleGeneratePreview(data), [JobName.GENERATE_THUMBNAILS]: (data) => this.mediaService.handleGenerateThumbnails(data),
[JobName.GENERATE_THUMBNAIL]: (data) => this.mediaService.handleGenerateThumbnail(data),
[JobName.GENERATE_THUMBHASH]: (data) => this.mediaService.handleGenerateThumbhash(data),
[JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data), [JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data),
[JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data), [JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),
[JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataService.handleQueueMetadataExtraction(data), [JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataService.handleQueueMetadataExtraction(data),

View File

@ -155,7 +155,7 @@ describe(NotificationService.name, () => {
it('should queue the generate thumbnail job', async () => { it('should queue the generate thumbnail job', async () => {
await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' }); await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' });
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.GENERATE_THUMBNAIL, name: JobName.GENERATE_THUMBNAILS,
data: { id: 'asset-id', notify: true }, data: { id: 'asset-id', notify: true },
}); });
}); });

View File

@ -65,7 +65,7 @@ export class NotificationService {
@OnEmit({ event: 'asset.show' }) @OnEmit({ event: 'asset.show' })
async onAssetShow({ assetId }: ArgOf<'asset.show'>) { async onAssetShow({ assetId }: ArgOf<'asset.show'>) {
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAIL, data: { id: assetId, notify: true } }); await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: { id: assetId, notify: true } });
} }
@OnEmit({ event: 'asset.trash' }) @OnEmit({ event: 'asset.trash' })

View File

@ -2,7 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto'; import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { CacheControl, Colorspace, SourceType, SystemMetadataKey } from 'src/enum'; import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
@ -961,12 +961,11 @@ describe(PersonService.name, () => {
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs');
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
assetStub.primaryImage.originalPath, assetStub.primaryImage.originalPath,
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
{ {
format: 'jpeg', colorspace: Colorspace.P3,
format: ImageFormat.JPEG,
size: 250, size: 250,
quality: 80, quality: 80,
colorspace: Colorspace.P3,
crop: { crop: {
left: 238, left: 238,
top: 163, top: 163,
@ -975,6 +974,7 @@ describe(PersonService.name, () => {
}, },
processInvalidImages: false, processInvalidImages: false,
}, },
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
); );
expect(personMock.update).toHaveBeenCalledWith({ expect(personMock.update).toHaveBeenCalledWith({
id: 'person-1', id: 'person-1',
@ -990,13 +990,12 @@ describe(PersonService.name, () => {
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
assetStub.image.originalPath, assetStub.primaryImage.originalPath,
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
{ {
format: 'jpeg', colorspace: Colorspace.P3,
format: ImageFormat.JPEG,
size: 250, size: 250,
quality: 80, quality: 80,
colorspace: Colorspace.P3,
crop: { crop: {
left: 0, left: 0,
top: 85, top: 85,
@ -1005,6 +1004,7 @@ describe(PersonService.name, () => {
}, },
processInvalidImages: false, processInvalidImages: false,
}, },
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
); );
}); });
@ -1017,12 +1017,11 @@ describe(PersonService.name, () => {
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
assetStub.primaryImage.originalPath, assetStub.primaryImage.originalPath,
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
{ {
format: 'jpeg', colorspace: Colorspace.P3,
format: ImageFormat.JPEG,
size: 250, size: 250,
quality: 80, quality: 80,
colorspace: Colorspace.P3,
crop: { crop: {
left: 591, left: 591,
top: 591, top: 591,
@ -1031,33 +1030,7 @@ describe(PersonService.name, () => {
}, },
processInvalidImages: false, processInvalidImages: false,
}, },
);
});
it('should use preview path for videos', async () => {
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId });
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end);
assetMock.getById.mockResolvedValue(assetStub.video);
mediaMock.getImageDimensions.mockResolvedValue({ width: 2560, height: 1440 });
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
'/uploads/user-id/thumbs/path.jpg',
'upload/thumbs/admin_id/pe/rs/person-1.jpeg', 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
{
format: 'jpeg',
size: 250,
quality: 80,
colorspace: Colorspace.P3,
crop: {
left: 1741,
top: 851,
width: 588,
height: 588,
},
processInvalidImages: false,
},
); );
}); });
}); });

View File

@ -571,15 +571,15 @@ export class PersonService {
this.storageCore.ensureFolders(thumbnailPath); this.storageCore.ensureFolders(thumbnailPath);
const thumbnailOptions = { const thumbnailOptions = {
colorspace: image.colorspace,
format: ImageFormat.JPEG, format: ImageFormat.JPEG,
size: FACE_THUMBNAIL_SIZE, size: FACE_THUMBNAIL_SIZE,
colorspace: image.colorspace,
quality: image.thumbnail.quality, quality: image.thumbnail.quality,
crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }), crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }),
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
} as const; };
await this.mediaRepository.generateThumbnail(inputPath, thumbnailPath, thumbnailOptions); await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath);
await this.repository.update({ id: person.id, thumbnailPath }); await this.repository.update({ id: person.id, thumbnailPath });
return JobStatus.SUCCESS; return JobStatus.SUCCESS;

View File

@ -39,5 +39,6 @@ export const newAssetRepositoryMock = (): Mocked<IAssetRepository> => {
getChangedDeltaSync: vitest.fn(), getChangedDeltaSync: vitest.fn(),
getDuplicates: vitest.fn(), getDuplicates: vitest.fn(),
upsertFile: vitest.fn(), upsertFile: vitest.fn(),
upsertFiles: vitest.fn(),
}; };
}; };

View File

@ -3,8 +3,9 @@ import { Mocked, vitest } from 'vitest';
export const newMediaRepositoryMock = (): Mocked<IMediaRepository> => { export const newMediaRepositoryMock = (): Mocked<IMediaRepository> => {
return { return {
generateThumbnail: vitest.fn(), generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
generateThumbhash: vitest.fn(), generateThumbhash: vitest.fn().mockImplementation(() => Promise.resolve()),
decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }),
extract: vitest.fn().mockResolvedValue(false), extract: vitest.fn().mockResolvedValue(false),
probe: vitest.fn(), probe: vitest.fn(),
transcode: vitest.fn(), transcode: vitest.fn(),