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:
parent
995f0fda47
commit
2bcd27e166
@ -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;
|
||||
};
|
||||
|
@ -492,7 +492,7 @@ class SystemConfigGeneratedImageDto {
|
||||
size!: number;
|
||||
}
|
||||
|
||||
class SystemConfigImageDto {
|
||||
export class SystemConfigImageDto {
|
||||
@Type(() => SystemConfigGeneratedImageDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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 }
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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'] });
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 };
|
||||
|
@ -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 () => {
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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> {
|
||||
|
@ -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),
|
||||
|
@ -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 },
|
||||
});
|
||||
});
|
||||
|
@ -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' })
|
||||
|
@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -39,5 +39,6 @@ export const newAssetRepositoryMock = (): Mocked<IAssetRepository> => {
|
||||
getChangedDeltaSync: vitest.fn(),
|
||||
getDuplicates: vitest.fn(),
|
||||
upsertFile: vitest.fn(),
|
||||
upsertFiles: vitest.fn(),
|
||||
};
|
||||
};
|
||||
|
@ -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(),
|
||||
|
Loading…
Reference in New Issue
Block a user