Skip to content

Commit

Permalink
feat(web/server): fullsize preview for non-web-friendly images
Browse files Browse the repository at this point in the history
  • Loading branch information
eligao committed Dec 16, 2024
1 parent ae1ef5a commit e22a123
Show file tree
Hide file tree
Showing 17 changed files with 166 additions and 120 deletions.
6 changes: 3 additions & 3 deletions mobile/openapi/lib/model/asset_media_size.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion mobile/openapi/lib/model/system_config_image_dto.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion open-api/immich-openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -8345,7 +8345,7 @@
},
"AssetMediaSize": {
"enum": [
"original",
"fullsize",
"preview",
"thumbnail"
],
Expand Down Expand Up @@ -11777,6 +11777,9 @@
"extractEmbedded": {
"type": "boolean"
},
"fullsizePreview": {
"type": "boolean"
},
"preview": {
"$ref": "#/components/schemas/SystemConfigGeneratedImageDto"
},
Expand All @@ -11787,6 +11790,7 @@
"required": [
"colorspace",
"extractEmbedded",
"fullsizePreview",
"preview",
"thumbnail"
],
Expand Down
3 changes: 2 additions & 1 deletion open-api/typescript-sdk/src/fetch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,7 @@ export type SystemConfigGeneratedImageDto = {
export type SystemConfigImageDto = {
colorspace: Colorspace;
extractEmbedded: boolean;
fullsizePreview: boolean;
preview: SystemConfigGeneratedImageDto;
thumbnail: SystemConfigGeneratedImageDto;
};
Expand Down Expand Up @@ -3460,7 +3461,7 @@ export enum AssetJobName {
TranscodeVideo = "transcode-video"
}
export enum AssetMediaSize {
Original = "original",
Fullsize = "fullsize",
Preview = "preview",
Thumbnail = "thumbnail"
}
Expand Down
2 changes: 2 additions & 0 deletions server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export interface SystemConfig {
preview: ImageOptions;
colorspace: Colorspace;
extractEmbedded: boolean;
fullsizePreview: boolean;
};
newVersionCheck: {
enabled: boolean;
Expand Down Expand Up @@ -281,6 +282,7 @@ export const defaults = Object.freeze<SystemConfig>({
},
colorspace: Colorspace.P3,
extractEmbedded: false,
fullsizePreview: false,
},
newVersionCheck: {
enabled: true,
Expand Down
6 changes: 3 additions & 3 deletions server/src/dtos/asset-media.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/valid

export enum AssetMediaSize {
/**
* An original-sized JPG extracted from the RAW image,
* or otherwise the original non-RAW image itself.
* An full-sized image extracted/converted from non-web-friendly formats like RAW/HIF.
* or otherwise the original image itself.
*/
ORIGINAL = 'original',
FULLSIZE = 'fullsize',
PREVIEW = 'preview',
THUMBNAIL = 'thumbnail',
}
Expand Down
3 changes: 3 additions & 0 deletions server/src/dtos/system-config.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,9 @@ export class SystemConfigImageDto {

@ValidateBoolean()
extractEmbedded!: boolean;

@ValidateBoolean()
fullsizePreview!: boolean;
}

class SystemConfigTrashDto {
Expand Down
6 changes: 4 additions & 2 deletions server/src/interfaces/media.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,10 @@ interface DecodeImageOptions {
export interface DecodeToBufferOptions extends DecodeImageOptions {
size?: number;
orientation?: ExifOrientation;
keepExif?: boolean;
}

export type GenerateThumbnailOptions = ImageOptions & DecodeImageOptions;
export type GenerateThumbnailOptions = Pick<ImageOptions, 'format' | 'quality'> & DecodeToBufferOptions;

export type GenerateThumbnailFromBufferOptions = GenerateThumbnailOptions & { raw: RawImageInfo };

Expand Down Expand Up @@ -137,7 +138,8 @@ export interface VideoInterfaces {

export interface IMediaRepository {
// image
extract(input: string, output: string): Promise<boolean>;
extract(input: string, output: string, withExif?: boolean): Promise<boolean>;
cloneExif(input: string, output: string): Promise<boolean>;
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>;
Expand Down
64 changes: 52 additions & 12 deletions server/src/repositories/media.repository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { exiftool } from 'exiftool-vendored';
import { BinaryField, exiftool } from 'exiftool-vendored';
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
import { Duration } from 'luxon';
import fs from 'node:fs/promises';
Expand Down Expand Up @@ -59,22 +59,63 @@ export class MediaRepository implements IMediaRepository {
return false;
}
}

return true;
}

async cloneExif(input: string, output: string): Promise<boolean> {
try {
// exclude some non-tag fields that interfere with writing back to the image
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { errors, warnings, OriginalFileName, FileName, Directory, ...exifTags } = await exiftool.read(input, {
ignoreMinorErrors: true,
});
this.logger.debug('Read exif data from original image:', exifTags);
if (errors?.length) {
this.logger.debug('Error reading exif data', JSON.stringify(errors));
}
if (warnings?.length) {
this.logger.debug('Warning reading exif data', JSON.stringify(warnings));
}
// filter out binary fields as they emit errors like:
// Could not extract exif data from image: cannot encode {"_ctor":"BinaryField","bytes":4815633,"rawValue":"(Binary data 4815633 bytes, use -b option to extract)"}
const exifTagsToWrite = Object.fromEntries(
Object.entries(exifTags).filter(([, value]) => !(value instanceof BinaryField)),
);
// GOTCHA: "Orientation" is read as a number by default,
// but when writing back, it has to be keyed "Orientation#"
// @see https://github.com/photostructure/exiftool-vendored.js/blob/f77b0f097fb26b68326d325caaf1642cf29cfe3d/src/WriteTags.ts#L22
if (exifTags.Orientation != null) {
exifTagsToWrite['Orientation#'] = exifTags.Orientation;
delete exifTagsToWrite['Orientation'];
}
const result = await exiftool.write(output, exifTagsToWrite, {
ignoreMinorErrors: true,
writeArgs: ['-overwrite_original'],
});
this.logger.debug('Wrote exif data to extracted image:', result);
return true;
} catch (error: any) {
this.logger.warn(`Could not extract exif data from image: ${error.message}`);
return false;
}
}

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

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
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
})
.toFile(output);
let pipeline = 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
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
});

if (options.keepExif === true) {
pipeline = pipeline.keepExif();
}

await pipeline.toFile(output);
}

private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
Expand Down Expand Up @@ -103,11 +144,10 @@ export class MediaRepository implements IMediaRepository {
pipeline = pipeline.extract(options.crop);
}

// No size, no resizing
if (options.size !== undefined) {
return pipeline;
pipeline = pipeline.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true });
}
return pipeline.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true });
return pipeline;
}

async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise<Buffer> {
Expand Down
10 changes: 5 additions & 5 deletions server/src/services/asset-media.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,16 +208,16 @@ export class AssetMediaService extends BaseService {
const asset = await this.findOrFail(id);
const size = dto.size ?? AssetMediaSize.THUMBNAIL;

const { thumbnailFile, previewFile, convertedFile } = getAssetFiles(asset.files);
const { thumbnailFile, previewFile, fullsizeFile } = getAssetFiles(asset.files);
let filepath = previewFile?.path;
if (size === AssetMediaSize.THUMBNAIL && thumbnailFile) {
filepath = thumbnailFile.path;
} else if (size === AssetMediaSize.ORIGINAL) {
} else if (size === AssetMediaSize.FULLSIZE) {
// eslint-disable-next-line unicorn/prefer-ternary
if (mimeTypes.isRaw(asset.originalPath)) {
filepath = convertedFile?.path ?? previewFile?.path;
} else {
if (mimeTypes.isWebSupportedImage(asset.originalPath)) {
filepath = asset.originalPath;
} else {
filepath = fullsizeFile?.path ?? previewFile?.path;
}
}

Expand Down
Loading

0 comments on commit e22a123

Please sign in to comment.