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:
parent
995f0fda47
commit
2bcd27e166
@ -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;
|
||||||
};
|
};
|
||||||
|
@ -492,7 +492,7 @@ class SystemConfigGeneratedImageDto {
|
|||||||
size!: number;
|
size!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SystemConfigImageDto {
|
export class SystemConfigImageDto {
|
||||||
@Type(() => SystemConfigGeneratedImageDto)
|
@Type(() => SystemConfigGeneratedImageDto)
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
@ -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 }
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
|
@ -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'] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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 };
|
||||||
|
@ -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 () => {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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> {
|
||||||
|
@ -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),
|
||||||
|
@ -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 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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' })
|
||||||
|
@ -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,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
|
@ -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(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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(),
|
||||||
|
Loading…
Reference in New Issue
Block a user