1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-27 10:58:13 +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,
} from 'src/enum';
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 {
ffmpeg: {
@ -110,8 +110,8 @@ export interface SystemConfig {
template: string;
};
image: {
thumbnail: ImageOutputConfig;
preview: ImageOutputConfig;
thumbnail: ImageOptions;
preview: ImageOptions;
colorspace: Colorspace;
extractEmbedded: boolean;
};

View File

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

View File

@ -141,6 +141,12 @@ export interface AssetUpdateDuplicateOptions {
duplicateIds: string[];
}
export interface UpsertFileOptions {
assetId: string;
type: AssetFileType;
path: string;
}
export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffline'>;
export const IAssetRepository = 'IAssetRepository';
@ -194,5 +200,6 @@ export interface IAssetRepository {
getDuplicates(options: AssetBuilderOptions): Promise<AssetEntity[]>;
getAllForUserFullSync(options: AssetFullSyncOptions): 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
QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails',
GENERATE_PREVIEW = 'generate-preview',
GENERATE_THUMBNAIL = 'generate-thumbnail',
GENERATE_THUMBHASH = 'generate-thumbhash',
GENERATE_THUMBNAILS = 'generate-thumbnails',
GENERATE_PERSON_THUMBNAIL = 'generate-person-thumbnail',
// metadata
@ -212,9 +210,7 @@ export type JobItem =
// Thumbnails
| { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob }
| { name: JobName.GENERATE_PREVIEW; data: IEntityJob }
| { name: JobName.GENERATE_THUMBNAIL; data: IEntityJob }
| { name: JobName.GENERATE_THUMBHASH; data: IEntityJob }
| { name: JobName.GENERATE_THUMBNAILS; data: IEntityJob }
// User
| { name: JobName.USER_DELETE_CHECK; data?: IBaseJob }

View File

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

View File

@ -1132,3 +1132,27 @@ RETURNING
"id",
"createdAt",
"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' }] })
async upsertFile({ assetId, type, path }: { assetId: string; type: AssetFileType; path: string }): Promise<void> {
await this.fileRepository.upsert({ assetId, type, path }, { conflictPaths: ['assetId', 'type'] });
async upsertFile(file: { assetId: string; type: AssetFileType; path: string }): Promise<void> {
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
[JobName.QUEUE_GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_PREVIEW]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_THUMBHASH]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
// tags

View File

@ -8,10 +8,12 @@ import sharp from 'sharp';
import { Colorspace, LogLevel } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import {
DecodeToBufferOptions,
GenerateThumbhashOptions,
GenerateThumbnailOptions,
IMediaRepository,
ImageDimensions,
ProbeOptions,
ThumbnailOptions,
TranscodeCommand,
VideoInfo,
} from 'src/interfaces/media.interface';
@ -57,19 +59,12 @@ export class MediaRepository implements IMediaRepository {
return true;
}
async generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise<void> {
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes
const pipeline = sharp(input, { failOn: options.processInvalidImages ? 'none' : 'error', limitInputPixels: false })
.pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16')
.rotate();
decodeImage(input: string, options: DecodeToBufferOptions) {
return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true });
}
if (options.crop) {
pipeline.extract(options.crop);
}
await pipeline
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
.withIccProfile(options.colorspace)
async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise<void> {
await this.getImageDecodingPipeline(input, options)
.toFormat(options.format, {
quality: options.quality,
// 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);
}
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> {
const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817
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> {
const { width = 0, height = 0 } = await sharp(input).metadata();
return { width, height };

View File

@ -395,7 +395,7 @@ describe(AssetService.name, () => {
it('should run the refresh thumbnails job', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
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 () => {

View File

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

View File

@ -288,7 +288,7 @@ describe(JobService.name, () => {
},
{
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' } },
@ -299,28 +299,16 @@ describe(JobService.name, () => {
jobs: [],
},
{
item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1' } },
jobs: [JobName.GENERATE_THUMBNAIL, JobName.GENERATE_THUMBHASH],
item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } },
jobs: [],
},
{
item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1', source: 'upload' } },
jobs: [
JobName.GENERATE_THUMBNAIL,
JobName.GENERATE_THUMBHASH,
JobName.SMART_SEARCH,
JobName.FACE_DETECTION,
JobName.VIDEO_CONVERSION,
],
item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1', source: 'upload' } },
jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION],
},
{
item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-live-image', source: 'upload' } },
jobs: [
JobName.GENERATE_THUMBNAIL,
JobName.GENERATE_THUMBHASH,
JobName.SMART_SEARCH,
JobName.FACE_DETECTION,
JobName.VIDEO_CONVERSION,
],
item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-live-image', source: 'upload' } },
jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION],
},
{
item: { name: JobName.SMART_SEARCH, data: { id: 'asset-1' } },
@ -338,11 +326,11 @@ describe(JobService.name, () => {
for (const { item, jobs } of tests) {
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') {
assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]);
assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]);
} 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 jobMock.addHandler.mock.calls[0][2](item);

View File

@ -281,7 +281,7 @@ export class JobService {
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
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;
}
@ -295,40 +295,33 @@ export class JobService {
break;
}
case JobName.GENERATE_PREVIEW: {
const jobs: JobItem[] = [
{ 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')) {
case JobName.GENERATE_THUMBNAILS: {
if (!item.data.notify && item.data.source !== 'upload') {
break;
}
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
if (asset && asset.isVisible) {
const jobs: JobItem[] = [
{ 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));
}
break;
}

View File

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

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 { GeneratedImageType, StorageCore } from 'src/cores/storage.core';
import { StorageCore } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { AssetEntity } from 'src/entities/asset.entity';
@ -18,7 +19,7 @@ import {
VideoCodec,
VideoContainer,
} 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 {
IBaseJob,
@ -95,18 +96,10 @@ export class MediaService {
for (const asset of assets) {
const { previewFile, thumbnailFile } = getAssetFiles(asset.files);
if (!previewFile || force) {
jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id: asset.id } });
if (!previewFile || !thumbnailFile || !asset.thumbhash || force) {
jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } });
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);
@ -181,141 +174,127 @@ export class MediaService {
return JobStatus.SUCCESS;
}
async handleGeneratePreview({ id }: IEntityJob): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true });
async handleGenerateThumbnails({ id }: IEntityJob): Promise<JobStatus> {
const asset = await this.assetRepository.getById(id, { exifInfo: true, files: true });
if (!asset) {
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`);
return JobStatus.FAILED;
}
if (!asset.isVisible) {
this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`);
return JobStatus.SKIPPED;
}
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW);
if (!previewPath) {
let generated: { previewPath: string; thumbnailPath: string; thumbhash: Buffer };
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;
}
const { previewFile } = getAssetFiles(asset.files);
if (previewFile && previewFile.path !== previewPath) {
const { previewFile, thumbnailFile } = getAssetFiles(asset.files);
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}`);
await this.storageRepository.unlink(previewFile.path);
pathsToDelete.push(previewFile.path);
}
await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.PREVIEW, path: previewPath });
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) {
if (thumbnailFile && thumbnailFile.path !== generated.thumbnailPath) {
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 });
await this.assetRepository.update({ id: asset.id, updatedAt: new Date() });
await this.assetRepository.upsertJobStatus({ assetId: asset.id, thumbnailAt: new Date() });
if (pathsToDelete.length > 0) {
await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path)));
}
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;
}
async handleGenerateThumbhash({ id }: IEntityJob): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id], { files: true });
if (!asset) {
return JobStatus.FAILED;
private async generateImageThumbnails(asset: AssetEntity) {
const { image } = 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 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) {
return JobStatus.SKIPPED;
private async generateVideoThumbnails(asset: AssetEntity) {
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);
if (!previewFile) {
return JobStatus.FAILED;
}
const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() });
const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() });
const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path);
await this.assetRepository.update({ id: asset.id, thumbhash });
const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
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> {

View File

@ -68,9 +68,7 @@ export class MicroservicesService {
[JobName.MIGRATE_ASSET]: (data) => this.mediaService.handleAssetMigration(data),
[JobName.MIGRATE_PERSON]: (data) => this.personService.handlePersonMigration(data),
[JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data),
[JobName.GENERATE_PREVIEW]: (data) => this.mediaService.handleGeneratePreview(data),
[JobName.GENERATE_THUMBNAIL]: (data) => this.mediaService.handleGenerateThumbnail(data),
[JobName.GENERATE_THUMBHASH]: (data) => this.mediaService.handleGenerateThumbhash(data),
[JobName.GENERATE_THUMBNAILS]: (data) => this.mediaService.handleGenerateThumbnails(data),
[JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data),
[JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(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 () => {
await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' });
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.GENERATE_THUMBNAIL,
name: JobName.GENERATE_THUMBNAILS,
data: { id: 'asset-id', notify: true },
});
});

View File

@ -65,7 +65,7 @@ export class NotificationService {
@OnEmit({ event: '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' })

View File

@ -2,7 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto';
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 { ICryptoRepository } from 'src/interfaces/crypto.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(mediaMock.generateThumbnail).toHaveBeenCalledWith(
assetStub.primaryImage.originalPath,
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
{
format: 'jpeg',
colorspace: Colorspace.P3,
format: ImageFormat.JPEG,
size: 250,
quality: 80,
colorspace: Colorspace.P3,
crop: {
left: 238,
top: 163,
@ -975,6 +974,7 @@ describe(PersonService.name, () => {
},
processInvalidImages: false,
},
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
);
expect(personMock.update).toHaveBeenCalledWith({
id: 'person-1',
@ -990,13 +990,12 @@ describe(PersonService.name, () => {
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
assetStub.image.originalPath,
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
assetStub.primaryImage.originalPath,
{
format: 'jpeg',
colorspace: Colorspace.P3,
format: ImageFormat.JPEG,
size: 250,
quality: 80,
colorspace: Colorspace.P3,
crop: {
left: 0,
top: 85,
@ -1005,6 +1004,7 @@ describe(PersonService.name, () => {
},
processInvalidImages: false,
},
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
);
});
@ -1017,12 +1017,11 @@ describe(PersonService.name, () => {
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
assetStub.primaryImage.originalPath,
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
{
format: 'jpeg',
colorspace: Colorspace.P3,
format: ImageFormat.JPEG,
size: 250,
quality: 80,
colorspace: Colorspace.P3,
crop: {
left: 591,
top: 591,
@ -1031,33 +1030,7 @@ describe(PersonService.name, () => {
},
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',
{
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);
const thumbnailOptions = {
colorspace: image.colorspace,
format: ImageFormat.JPEG,
size: FACE_THUMBNAIL_SIZE,
colorspace: image.colorspace,
quality: image.thumbnail.quality,
crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }),
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 });
return JobStatus.SUCCESS;

View File

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

View File

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