feat(server): add jxl as target format (#32732)

(cherry picked from commit 9560989cb97fb93cd6ab74f7811acce133a9b768)
This commit is contained in:
Mert
2026-10-27 23:12:52 +03:00
committed by Codex
parent f1a09c1389
commit db411a4497
15 changed files with 167 additions and 30 deletions
+3
View File
@@ -64,6 +64,9 @@ export const excludePaths = ['/.well-known/immich', '/.well-known/webauthn', '/c
export const FACE_THUMBNAIL_SIZE = 250;
// The fastest JPEG XL tier skips smoothing filters, which also keeps small thumbnails sharper
export const THUMBNAIL_DECODING_TIER = 4;
type ModelInfo = { dimSize: number };
export const CLIP_MODEL_INFO: Record<string, ModelInfo> = {
RN101__openai: { dimSize: 512 },
+1
View File
@@ -610,6 +610,7 @@ export const ColorspaceSchema = z.enum(Colorspace).describe('Colorspace').meta({
export enum ImageFormat {
Jpeg = 'jpeg',
Jxl = 'jxl',
Webp = 'webp',
}
@@ -331,7 +331,13 @@ describe(MediaRepository.name, () => {
const decoded = await sut.decodeImage(buffer, { colorspace: Colorspace.P3, processInvalidImages: false });
await sut.generateThumbnail(
decoded,
{ colorspace: Colorspace.P3, quality: 80, format: ImageFormat.Webp, processInvalidImages: false },
{
colorspace: Colorspace.P3,
quality: 80,
format: ImageFormat.Webp,
processInvalidImages: false,
decodingTier: 0,
},
file,
);
@@ -186,6 +186,7 @@ export class MediaRepository {
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
progressive: options.progressive,
decodingTier: options.decodingTier,
})
.toFile(output);
}
+63
View File
@@ -454,6 +454,7 @@ describe(MediaService.name, () => {
progressive: false,
processInvalidImages: false,
edits: [],
decodingTier: 0,
},
expect.any(String),
);
@@ -467,6 +468,7 @@ describe(MediaService.name, () => {
progressive: false,
processInvalidImages: false,
edits: [],
decodingTier: 4,
},
expect.any(String),
);
@@ -551,6 +553,30 @@ describe(MediaService.name, () => {
]);
});
it('should generate WebP video thumbnails when JPEG XL is configured', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
mocks.systemMetadata.get.mockResolvedValue({
image: { preview: { format: ImageFormat.Jxl }, thumbnail: { format: ImageFormat.Jxl } },
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({
...getForGenerateThumbnail(asset),
...probeStub.videoStream2160p,
});
await sut.handleGenerateThumbnails({ id: asset.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
expect.stringMatching(/_preview\.webp$/),
expect.any(Object),
);
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
expect.stringMatching(/_thumbnail\.webp$/),
expect.any(Object),
);
});
it('should tonemap thumbnail for hdr video', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({
@@ -726,6 +752,7 @@ describe(MediaService.name, () => {
progressive: false,
processInvalidImages: false,
edits: [],
decodingTier: 0,
},
previewPath,
);
@@ -739,6 +766,7 @@ describe(MediaService.name, () => {
progressive: false,
processInvalidImages: false,
edits: [],
decodingTier: 4,
},
thumbnailPath,
);
@@ -774,6 +802,7 @@ describe(MediaService.name, () => {
progressive: false,
processInvalidImages: false,
edits: [],
decodingTier: 0,
},
previewPath,
);
@@ -787,6 +816,7 @@ describe(MediaService.name, () => {
progressive: false,
processInvalidImages: false,
edits: [],
decodingTier: 4,
},
thumbnailPath,
);
@@ -870,6 +900,24 @@ describe(MediaService.name, () => {
]);
});
it('should never set isProgressive for JPEG XL', async () => {
const asset = AssetFactory.from().exif().build();
mocks.systemMetadata.get.mockResolvedValue({
image: {
preview: { format: ImageFormat.Jxl, progressive: true },
thumbnail: { format: ImageFormat.Jxl, progressive: true },
},
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
expect.objectContaining({ type: AssetFileType.Preview, isProgressive: false }),
expect.objectContaining({ type: AssetFileType.Thumbnail, isProgressive: false }),
]);
});
it('should never set isProgressive for videos', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
mocks.systemMetadata.get.mockResolvedValue({
@@ -1079,6 +1127,7 @@ describe(MediaService.name, () => {
progressive: false,
processInvalidImages: false,
edits: [],
decodingTier: 0,
},
expect.any(String),
);
@@ -1114,6 +1163,7 @@ describe(MediaService.name, () => {
progressive: false,
processInvalidImages: false,
edits: [],
decodingTier: 0,
},
expect.any(String),
);
@@ -1127,6 +1177,7 @@ describe(MediaService.name, () => {
progressive: false,
processInvalidImages: false,
edits: [],
decodingTier: 0,
},
expect.any(String),
);
@@ -1160,6 +1211,7 @@ describe(MediaService.name, () => {
progressive: false,
processInvalidImages: false,
edits: [],
decodingTier: 0,
},
expect.any(String),
);
@@ -1173,6 +1225,7 @@ describe(MediaService.name, () => {
size: 1440,
processInvalidImages: false,
edits: [],
decodingTier: 0,
},
expect.any(String),
);
@@ -1210,6 +1263,7 @@ describe(MediaService.name, () => {
progressive: false,
processInvalidImages: false,
edits: [],
decodingTier: 0,
},
expect.any(String),
);
@@ -1274,6 +1328,7 @@ describe(MediaService.name, () => {
progressive: false,
processInvalidImages: false,
edits: [],
decodingTier: 0,
},
expect.any(String),
);
@@ -1316,6 +1371,7 @@ describe(MediaService.name, () => {
progressive: false,
processInvalidImages: false,
edits: [],
decodingTier: 0,
},
expect.any(String),
);
@@ -1577,6 +1633,7 @@ describe(MediaService.name, () => {
],
processInvalidImages: false,
size: 250,
decodingTier: 4,
},
expect.any(String),
);
@@ -1630,6 +1687,7 @@ describe(MediaService.name, () => {
],
processInvalidImages: false,
size: 250,
decodingTier: 4,
},
expect.any(String),
);
@@ -1678,6 +1736,7 @@ describe(MediaService.name, () => {
],
processInvalidImages: false,
size: 250,
decodingTier: 4,
},
expect.any(String),
);
@@ -1722,6 +1781,7 @@ describe(MediaService.name, () => {
],
processInvalidImages: false,
size: 250,
decodingTier: 4,
},
expect.any(String),
);
@@ -1766,6 +1826,7 @@ describe(MediaService.name, () => {
],
processInvalidImages: false,
size: 250,
decodingTier: 4,
},
expect.any(String),
);
@@ -1810,6 +1871,7 @@ describe(MediaService.name, () => {
],
processInvalidImages: false,
size: 250,
decodingTier: 4,
},
expect.any(String),
);
@@ -1859,6 +1921,7 @@ describe(MediaService.name, () => {
],
processInvalidImages: false,
size: 250,
decodingTier: 4,
},
expect.any(String),
);
+18 -10
View File
@@ -10,7 +10,7 @@ import type {
VideoInterfaces,
VideoStreamInfo,
} from 'src/types.js';
import { FACE_THUMBNAIL_SIZE } from 'src/constants.js';
import { FACE_THUMBNAIL_SIZE, THUMBNAIL_DECODING_TIER } from 'src/constants.js';
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core.js';
import { AssetFile } from 'src/database.js';
import { OnEvent, OnJob } from 'src/decorators.js';
@@ -303,14 +303,14 @@ export class MediaService extends BaseService {
fileType: AssetFileType.Preview,
format: previewFormat,
isEdited: useEdits,
isProgressive: !!image.preview.progressive && previewFormat !== ImageFormat.Webp,
isProgressive: !!image.preview.progressive && previewFormat === ImageFormat.Jpeg,
isTransparent,
});
const thumbnailFile = this.getImageFile(asset, {
fileType: AssetFileType.Thumbnail,
format: thumbnailFormat,
isEdited: useEdits,
isProgressive: !!image.thumbnail.progressive && thumbnailFormat !== ImageFormat.Webp,
isProgressive: !!image.thumbnail.progressive && thumbnailFormat === ImageFormat.Jpeg,
isTransparent,
});
this.storageCore.ensureFolders(previewFile.path);
@@ -318,8 +318,13 @@ export class MediaService extends BaseService {
// generate final images
const decoded = { data, info };
const baseOptions = { colorspace, processInvalidImages: false, edits: useEdits ? asset.edits : [] };
const thumbnailOptions = { ...image.thumbnail, ...baseOptions, format: thumbnailFormat };
const previewOptions = { ...image.preview, ...baseOptions, format: previewFormat };
const thumbnailOptions = {
...image.thumbnail,
...baseOptions,
format: thumbnailFormat,
decodingTier: THUMBNAIL_DECODING_TIER,
} as const;
const previewOptions = { ...image.preview, ...baseOptions, format: previewFormat, decodingTier: 0 } as const;
const promises = [
this.mediaRepository.generateThumbhash(decoded, baseOptions),
this.mediaRepository.generateThumbnail(decoded, thumbnailOptions, thumbnailFile.path),
@@ -335,7 +340,7 @@ export class MediaService extends BaseService {
fileType: AssetFileType.FullSize,
format: fullsizeFormat,
isEdited: useEdits,
isProgressive: !!image.fullsize.progressive && fullsizeFormat !== ImageFormat.Webp,
isProgressive: !!image.fullsize.progressive && fullsizeFormat === ImageFormat.Jpeg,
isTransparent,
});
const fullsizeOptions = {
@@ -343,14 +348,15 @@ export class MediaService extends BaseService {
format: fullsizeFormat,
quality: image.fullsize.quality,
progressive: image.fullsize.progressive,
};
decodingTier: 0,
} as const;
promises.push(this.mediaRepository.generateThumbnail(decoded, fullsizeOptions, fullsizeFile.path));
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
fullsizeFile = this.getImageFile(asset, {
fileType: AssetFileType.FullSize,
format: extracted.format,
isEdited: false,
isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp,
isProgressive: !!image.fullsize.progressive && image.fullsize.format === ImageFormat.Jpeg,
isTransparent,
});
this.storageCore.ensureFolders(fullsizeFile.path);
@@ -444,6 +450,7 @@ export class MediaService extends BaseService {
),
},
],
decodingTier: THUMBNAIL_DECODING_TIER,
};
await this.mediaRepository.generateThumbnail(decoded, thumbnailOptions, thumbnailPath);
@@ -493,14 +500,15 @@ export class MediaService extends BaseService {
private async generateVideoThumbnails(asset: ThumbnailAsset, { ffmpeg, image }: SystemConfig) {
const previewFile = this.getImageFile(asset, {
fileType: AssetFileType.Preview,
format: image.preview.format,
// TODO: write JPEG XL for video thumbnails
format: image.preview.format === ImageFormat.Jxl ? ImageFormat.Webp : image.preview.format,
isEdited: false,
isProgressive: false,
isTransparent: false,
});
const thumbnailFile = this.getImageFile(asset, {
fileType: AssetFileType.Thumbnail,
format: image.thumbnail.format,
format: image.thumbnail.format === ImageFormat.Jxl ? ImageFormat.Webp : image.thumbnail.format,
isEdited: false,
isProgressive: false,
isTransparent: false,
+4 -11
View File
@@ -43,20 +43,15 @@ export type DeepPartial<T> = T extends Date
export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
export type FullsizeImageOptions = {
format: ImageFormat;
quality: number;
enabled: boolean;
progressive?: boolean;
};
export type ImageOptions = {
format: ImageFormat;
quality: number;
size: number;
progressive?: boolean;
decodingTier: 0 | 1 | 2 | 3 | 4;
};
export type FullsizeImageOptions = ImageOptions & { enabled: boolean };
export type RawImageInfo = {
width: number;
height: number;
@@ -84,9 +79,7 @@ export type TransformOptions = {
edits?: AssetEditActionItem[];
};
export type GenerateThumbnailOptions = Pick<ImageOptions, 'format' | 'quality' | 'progressive'> &
ImageColorOptions &
TransformOptions;
export type GenerateThumbnailOptions = ImageOptions & ImageColorOptions & TransformOptions;
export type GenerateThumbhashOptions = ImageColorOptions & Pick<TransformOptions, 'edits'>;
export interface VideoStreamInfo {
+2
View File
@@ -1,4 +1,5 @@
import { join } from 'node:path';
import { THUMBNAIL_DECODING_TIER } from 'src/constants.js';
import { StorageCore } from 'src/cores/storage.core.js';
import { SystemConfig } from 'src/dtos/config.dto.js';
import { StorageFolder } from 'src/enum.js';
@@ -33,6 +34,7 @@ export const generateProfileImage = async (
progressive: image.thumbnail.progressive,
size: image.thumbnail.size,
processInvalidImages: false,
decodingTier: THUMBNAIL_DECODING_TIER,
},
outputPath,
);