mirror of
https://github.com/immich-app/immich.git
synced 2025-01-19 18:26:46 +01:00
feat(server): extract full-size previews from RAW images
This commit is contained in:
parent
ef0070c3fd
commit
61d40af7e9
16 changed files with 124 additions and 43 deletions
BIN
mobile/openapi/lib/model/asset_media_size.dart
generated
BIN
mobile/openapi/lib/model/asset_media_size.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/path_type.dart
generated
BIN
mobile/openapi/lib/model/path_type.dart
generated
Binary file not shown.
|
@ -8406,6 +8406,7 @@
|
|||
},
|
||||
"AssetMediaSize": {
|
||||
"enum": [
|
||||
"original",
|
||||
"preview",
|
||||
"thumbnail"
|
||||
],
|
||||
|
@ -10186,6 +10187,7 @@
|
|||
"PathType": {
|
||||
"enum": [
|
||||
"original",
|
||||
"extracted",
|
||||
"preview",
|
||||
"thumbnail",
|
||||
"encoded_video",
|
||||
|
|
|
@ -3464,6 +3464,7 @@ export enum AssetJobName {
|
|||
TranscodeVideo = "transcode-video"
|
||||
}
|
||||
export enum AssetMediaSize {
|
||||
Original = "original",
|
||||
Preview = "preview",
|
||||
Thumbnail = "thumbnail"
|
||||
}
|
||||
|
@ -3514,6 +3515,7 @@ export enum PathEntityType {
|
|||
}
|
||||
export enum PathType {
|
||||
Original = "original",
|
||||
Extracted = "extracted",
|
||||
Preview = "preview",
|
||||
Thumbnail = "thumbnail",
|
||||
EncodedVideo = "encoded_video",
|
||||
|
|
|
@ -26,7 +26,7 @@ export interface MoveRequest {
|
|||
};
|
||||
}
|
||||
|
||||
export type GeneratedImageType = AssetPathType.PREVIEW | AssetPathType.THUMBNAIL;
|
||||
export type GeneratedImageType = AssetPathType.PREVIEW | AssetPathType.THUMBNAIL | AssetPathType.EXTRACTED;
|
||||
export type GeneratedAssetType = GeneratedImageType | AssetPathType.ENCODED_VIDEO;
|
||||
|
||||
let instance: StorageCore | null;
|
||||
|
|
|
@ -4,6 +4,11 @@ import { ArrayNotEmpty, IsArray, IsEnum, IsNotEmpty, IsString, ValidateNested }
|
|||
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
|
||||
|
||||
export enum AssetMediaSize {
|
||||
/**
|
||||
* An original-sized JPG extracted from the RAW image,
|
||||
* or otherwise the original non-RAW image itself.
|
||||
*/
|
||||
ORIGINAL = 'original',
|
||||
PREVIEW = 'preview',
|
||||
THUMBNAIL = 'thumbnail',
|
||||
}
|
||||
|
|
|
@ -33,6 +33,10 @@ export enum AssetType {
|
|||
}
|
||||
|
||||
export enum AssetFileType {
|
||||
/**
|
||||
* An full/large-size image extracted/converted from RAW photos
|
||||
*/
|
||||
EXTRACTED = 'extracted',
|
||||
PREVIEW = 'preview',
|
||||
THUMBNAIL = 'thumbnail',
|
||||
}
|
||||
|
@ -237,6 +241,7 @@ export enum ManualJobName {
|
|||
|
||||
export enum AssetPathType {
|
||||
ORIGINAL = 'original',
|
||||
EXTRACTED = 'extracted',
|
||||
PREVIEW = 'preview',
|
||||
THUMBNAIL = 'thumbnail',
|
||||
ENCODED_VIDEO = 'encoded_video',
|
||||
|
|
|
@ -185,6 +185,7 @@ export interface IAssetRepository {
|
|||
updateDuplicates(options: AssetUpdateDuplicateOptions): Promise<void>;
|
||||
update(asset: AssetUpdateOptions): Promise<void>;
|
||||
remove(asset: AssetEntity): Promise<void>;
|
||||
removeAssetFile(path: string): Promise<void>;
|
||||
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
|
||||
getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>;
|
||||
getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]>;
|
||||
|
|
|
@ -298,6 +298,10 @@ export class AssetRepository implements IAssetRepository {
|
|||
await this.repository.remove(asset);
|
||||
}
|
||||
|
||||
async removeAssetFile(path: string): Promise<void> {
|
||||
await this.fileRepository.delete({ path });
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, libraryId: DummyValue.UUID, checksum: DummyValue.BUFFER }] })
|
||||
getByChecksum({
|
||||
ownerId,
|
||||
|
|
|
@ -44,6 +44,11 @@ export class MediaRepository implements IMediaRepository {
|
|||
|
||||
async extract(input: string, output: string): Promise<boolean> {
|
||||
try {
|
||||
// remove existing output file if it exists
|
||||
// as exiftool-vendord does not support overwriting via "-w!" flag
|
||||
// and throws "1 files could not be read" error when the output file exists
|
||||
await fs.unlink(output).catch(() => null);
|
||||
this.logger.debug('Extracting JPEG from RAW image:', input);
|
||||
await exiftool.extractJpgFromRaw(input, output);
|
||||
} catch (error: any) {
|
||||
this.logger.debug('Could not extract JPEG from image, trying preview', error.message);
|
||||
|
@ -98,6 +103,10 @@ export class MediaRepository implements IMediaRepository {
|
|||
pipeline = pipeline.extract(options.crop);
|
||||
}
|
||||
|
||||
// Infinity is a special value that means no resizing
|
||||
if (options.size === Infinity) {
|
||||
return pipeline;
|
||||
}
|
||||
return pipeline.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true });
|
||||
}
|
||||
|
||||
|
|
|
@ -208,10 +208,17 @@ export class AssetMediaService extends BaseService {
|
|||
const asset = await this.findOrFail(id);
|
||||
const size = dto.size ?? AssetMediaSize.THUMBNAIL;
|
||||
|
||||
const { thumbnailFile, previewFile } = getAssetFiles(asset.files);
|
||||
const { thumbnailFile, previewFile, extractedFile } = getAssetFiles(asset.files);
|
||||
let filepath = previewFile?.path;
|
||||
if (size === AssetMediaSize.THUMBNAIL && thumbnailFile) {
|
||||
filepath = thumbnailFile.path;
|
||||
} else if (size === AssetMediaSize.ORIGINAL) {
|
||||
// eslint-disable-next-line unicorn/prefer-ternary
|
||||
if (mimeTypes.isRaw(asset.originalPath)) {
|
||||
filepath = extractedFile?.path ?? previewFile?.path;
|
||||
} else {
|
||||
filepath = asset.originalPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (!filepath) {
|
||||
|
|
|
@ -269,7 +269,7 @@ describe(MediaService.name, () => {
|
|||
});
|
||||
|
||||
await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
|
||||
expect(moveMock.create).toHaveBeenCalledTimes(2);
|
||||
expect(moveMock.create).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -634,8 +634,6 @@ describe(MediaService.name, () => {
|
|||
processInvalidImages: false,
|
||||
size: 1440,
|
||||
});
|
||||
expect(extractedPath?.endsWith('.tmp')).toBe(true);
|
||||
expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath);
|
||||
});
|
||||
|
||||
it('should resize original image if embedded image is too small', async () => {
|
||||
|
@ -646,15 +644,19 @@ describe(MediaService.name, () => {
|
|||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString();
|
||||
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
|
||||
assetStub.imageDng.originalPath,
|
||||
expect.objectContaining({ size: Infinity }),
|
||||
extractedPath,
|
||||
);
|
||||
expect(extractedPath).toMatch(/-extracted\.jpeg$/);
|
||||
expect(mediaMock.decodeImage).toHaveBeenCalledOnce();
|
||||
expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, {
|
||||
expect(mediaMock.decodeImage).toHaveBeenCalledWith(extractedPath, {
|
||||
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 not found', async () => {
|
||||
|
@ -664,7 +666,7 @@ describe(MediaService.name, () => {
|
|||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mediaMock.decodeImage).toHaveBeenCalledOnce();
|
||||
expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, {
|
||||
expect(mediaMock.decodeImage).toHaveBeenCalledWith('upload/thumbs/user-id/as/se/asset-id-extracted.jpeg', {
|
||||
colorspace: Colorspace.P3,
|
||||
processInvalidImages: false,
|
||||
size: 1440,
|
||||
|
@ -680,7 +682,7 @@ describe(MediaService.name, () => {
|
|||
|
||||
expect(mediaMock.extract).not.toHaveBeenCalled();
|
||||
expect(mediaMock.decodeImage).toHaveBeenCalledOnce();
|
||||
expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, {
|
||||
expect(mediaMock.decodeImage).toHaveBeenCalledWith('upload/thumbs/user-id/as/se/asset-id-extracted.jpeg', {
|
||||
colorspace: Colorspace.P3,
|
||||
processInvalidImages: false,
|
||||
size: 1440,
|
||||
|
@ -697,11 +699,16 @@ describe(MediaService.name, () => {
|
|||
|
||||
expect(mediaMock.decodeImage).toHaveBeenCalledOnce();
|
||||
expect(mediaMock.decodeImage).toHaveBeenCalledWith(
|
||||
assetStub.imageDng.originalPath,
|
||||
'upload/thumbs/user-id/as/se/asset-id-extracted.jpeg',
|
||||
expect.objectContaining({ processInvalidImages: true }),
|
||||
);
|
||||
|
||||
expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2);
|
||||
expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(3);
|
||||
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
|
||||
assetStub.imageDng.originalPath,
|
||||
expect.objectContaining({ processInvalidImages: true }),
|
||||
'upload/thumbs/user-id/as/se/asset-id-extracted.jpeg',
|
||||
);
|
||||
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.objectContaining({ processInvalidImages: true }),
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { dirname } from 'node:path';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
|
@ -10,6 +9,7 @@ import {
|
|||
AssetType,
|
||||
AudioCodec,
|
||||
Colorspace,
|
||||
ImageFormat,
|
||||
LogLevel,
|
||||
StorageFolder,
|
||||
TranscodeHWAccel,
|
||||
|
@ -135,6 +135,7 @@ export class MediaService extends BaseService {
|
|||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
await this.storageCore.moveAssetImage(asset, AssetPathType.EXTRACTED, ImageFormat.JPEG);
|
||||
await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.preview.format);
|
||||
await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
|
||||
await this.storageCore.moveAssetVideo(asset);
|
||||
|
@ -155,7 +156,12 @@ export class MediaService extends BaseService {
|
|||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
let generated: { previewPath: string; thumbnailPath: string; thumbhash: Buffer };
|
||||
let generated: {
|
||||
previewPath: string;
|
||||
thumbnailPath: string;
|
||||
extractedPath?: string;
|
||||
thumbhash: Buffer;
|
||||
};
|
||||
if (asset.type === AssetType.VIDEO || asset.originalFileName.toLowerCase().endsWith('.gif')) {
|
||||
generated = await this.generateVideoThumbnails(asset);
|
||||
} else if (asset.type === AssetType.IMAGE) {
|
||||
|
@ -165,7 +171,7 @@ export class MediaService extends BaseService {
|
|||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const { previewFile, thumbnailFile } = getAssetFiles(asset.files);
|
||||
const { previewFile, thumbnailFile, extractedFile } = getAssetFiles(asset.files);
|
||||
const toUpsert: UpsertFileOptions[] = [];
|
||||
if (previewFile?.path !== generated.previewPath) {
|
||||
toUpsert.push({ assetId: asset.id, path: generated.previewPath, type: AssetFileType.PREVIEW });
|
||||
|
@ -175,11 +181,15 @@ export class MediaService extends BaseService {
|
|||
toUpsert.push({ assetId: asset.id, path: generated.thumbnailPath, type: AssetFileType.THUMBNAIL });
|
||||
}
|
||||
|
||||
if (generated.extractedPath && extractedFile?.path !== generated.extractedPath) {
|
||||
toUpsert.push({ assetId: asset.id, path: generated.extractedPath, type: AssetFileType.EXTRACTED });
|
||||
}
|
||||
|
||||
if (toUpsert.length > 0) {
|
||||
await this.assetRepository.upsertFiles(toUpsert);
|
||||
}
|
||||
|
||||
const pathsToDelete = [];
|
||||
const pathsToDelete: string[] = [];
|
||||
if (previewFile && previewFile.path !== generated.previewPath) {
|
||||
this.logger.debug(`Deleting old preview for asset ${asset.id}`);
|
||||
pathsToDelete.push(previewFile.path);
|
||||
|
@ -190,8 +200,18 @@ export class MediaService extends BaseService {
|
|||
pathsToDelete.push(thumbnailFile.path);
|
||||
}
|
||||
|
||||
if (extractedFile && extractedFile.path !== generated.extractedPath) {
|
||||
this.logger.debug(`Deleting old extracted image for asset ${asset.id}`);
|
||||
pathsToDelete.push(extractedFile.path);
|
||||
}
|
||||
|
||||
if (pathsToDelete.length > 0) {
|
||||
await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path)));
|
||||
await Promise.all(
|
||||
pathsToDelete.map(async (path) => {
|
||||
await this.storageRepository.unlink(path);
|
||||
await this.assetRepository.removeAssetFile(path);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (asset.thumbhash != generated.thumbhash) {
|
||||
|
@ -209,33 +229,50 @@ export class MediaService extends BaseService {
|
|||
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));
|
||||
const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true';
|
||||
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace;
|
||||
const decodeOptions = { colorspace, processInvalidImages, size: image.preview.size };
|
||||
|
||||
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 orientation = useExtracted && asset.exifInfo?.orientation ? Number(asset.exifInfo.orientation) : undefined;
|
||||
const decodeOptions = { colorspace, processInvalidImages, size: image.preview.size, orientation };
|
||||
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);
|
||||
let fullsizePath: string;
|
||||
let extractedPath: string | undefined;
|
||||
if (mimeTypes.isRaw(asset.originalPath)) {
|
||||
let useExtracted = false;
|
||||
extractedPath = StorageCore.getImagePath(asset, AssetPathType.EXTRACTED, ImageFormat.JPEG);
|
||||
if (image.extractEmbedded) {
|
||||
// try extracting embedded preview from RAW
|
||||
// extracted image from RAW is always in JPEG format, as implied from the `jpgFromRaw` tag name
|
||||
const didExtract = await this.mediaRepository.extract(asset.originalPath, extractedPath);
|
||||
useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size));
|
||||
}
|
||||
|
||||
if (useExtracted) {
|
||||
fullsizePath = extractedPath;
|
||||
} else {
|
||||
// did not extract or extracted preview is smaller than target size,
|
||||
// convert a full-sized thumbnail from original instead
|
||||
extractedPath = StorageCore.getImagePath(asset, AssetPathType.EXTRACTED, image.preview.format);
|
||||
// const orientation = asset.exifInfo?.orientation ? Number(asset.exifInfo.orientation) : undefined;
|
||||
await this.mediaRepository.generateThumbnail(
|
||||
asset.originalPath,
|
||||
{ ...image.preview, colorspace, processInvalidImages, size: Infinity },
|
||||
extractedPath,
|
||||
);
|
||||
}
|
||||
fullsizePath = extractedPath;
|
||||
} else {
|
||||
fullsizePath = asset.originalPath;
|
||||
}
|
||||
|
||||
const { info, data } = await this.mediaRepository.decodeImage(fullsizePath, decodeOptions);
|
||||
|
||||
const thumbnailOptions = { colorspace, processInvalidImages, raw: info };
|
||||
const outputs = await Promise.all([
|
||||
this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailPath),
|
||||
this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewPath),
|
||||
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
|
||||
]);
|
||||
|
||||
return { previewPath, thumbnailPath, extractedPath, thumbhash: outputs[2] };
|
||||
}
|
||||
|
||||
private async generateVideoThumbnails(asset: AssetEntity) {
|
||||
|
|
|
@ -25,6 +25,7 @@ const getFileByType = (files: AssetFileEntity[] | undefined, type: AssetFileType
|
|||
};
|
||||
|
||||
export const getAssetFiles = (files?: AssetFileEntity[]) => ({
|
||||
extractedFile: getFileByType(files, AssetFileType.EXTRACTED),
|
||||
previewFile: getFileByType(files, AssetFileType.PREVIEW),
|
||||
thumbnailFile: getFileByType(files, AssetFileType.THUMBNAIL),
|
||||
});
|
||||
|
|
|
@ -46,7 +46,7 @@ export const sendFile = async (
|
|||
const file = await handler();
|
||||
switch (file.cacheControl) {
|
||||
case CacheControl.PRIVATE_WITH_CACHE: {
|
||||
res.set('Cache-Control', 'private, max-age=86400, no-transform');
|
||||
res.set('Cache-Control', 'private, max-age=3600, stale-while-revalidate=86400, no-transform');
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ export const newAssetRepositoryMock = (): Mocked<IAssetRepository> => {
|
|||
deleteAll: vitest.fn(),
|
||||
update: vitest.fn(),
|
||||
remove: vitest.fn(),
|
||||
removeAssetFile: vitest.fn(),
|
||||
findLivePhotoMatch: vitest.fn(),
|
||||
getStatistics: vitest.fn(),
|
||||
getTimeBucket: vitest.fn(),
|
||||
|
|
Loading…
Reference in a new issue