Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 6 commits into from
Sep 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -110,8 +110,8 @@ export interface SystemConfig {
template: string;
};
image: {
thumbnail: ImageOutputConfig;
preview: ImageOutputConfig;
thumbnail: ImageOptions;
preview: ImageOptions;
colorspace: Colorspace;
extractEmbedded: boolean;
};
Expand Down
2 changes: 1 addition & 1 deletion server/src/dtos/system-config.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,7 @@ class SystemConfigGeneratedImageDto {
size!: number;
}

class SystemConfigImageDto {
export class SystemConfigImageDto {
@Type(() => SystemConfigGeneratedImageDto)
@ValidateNested()
@IsObject()
Expand Down
9 changes: 8 additions & 1 deletion server/src/interfaces/asset.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>;
}
8 changes: 2 additions & 6 deletions server/src/interfaces/job.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
44 changes: 40 additions & 4 deletions server/src/interfaces/media.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions server/src/queries/asset.repository.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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"
9 changes: 7 additions & 2 deletions server/src/repositories/asset.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] });
}
}
4 changes: 1 addition & 3 deletions server/src/repositories/job.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 42 additions & 26 deletions server/src/repositories/media.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();

if (options.crop) {
pipeline.extract(options.crop);
}
decodeImage(input: string, options: DecodeToBufferOptions) {
return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true });
}

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
Expand All @@ -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 {
Expand Down Expand Up @@ -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 };
Expand Down
2 changes: 1 addition & 1 deletion server/src/services/asset.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion server/src/services/asset.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
34 changes: 11 additions & 23 deletions server/src/services/job.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
Expand All @@ -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' } },
Expand All @@ -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]);
}
}

Expand All @@ -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);

Expand Down
Loading
Loading