1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-06 03:46:47 +01:00

feat: tweaks for code review

This commit is contained in:
Eli Gao 2024-12-18 00:41:29 +08:00
parent 5c40b52a1c
commit 97b1261095
4 changed files with 51 additions and 51 deletions

View file

@ -1222,7 +1222,7 @@ describe('/asset', () => {
}, },
]; ];
it(`should upload and generate a thumbnail for different file types`, { timeout: 60_000 }, async () => { it(`should upload and generate a thumbnail for different file types`, async () => {
// upload in parallel // upload in parallel
const assets = await Promise.all( const assets = await Promise.all(
tests.map(async ({ input }) => { tests.map(async ({ input }) => {

View file

@ -1,4 +1,5 @@
import { Writable } from 'node:stream'; import { Writable } from 'node:stream';
import { ExifEntity } from 'src/entities/exif.entity';
import { ExifOrientation, ImageFormat, TranscodeTarget, VideoCodec } from 'src/enum'; import { ExifOrientation, ImageFormat, TranscodeTarget, VideoCodec } from 'src/enum';
export const IMediaRepository = 'IMediaRepository'; export const IMediaRepository = 'IMediaRepository';
@ -32,7 +33,6 @@ interface DecodeImageOptions {
export interface DecodeToBufferOptions extends DecodeImageOptions { export interface DecodeToBufferOptions extends DecodeImageOptions {
size?: number; size?: number;
orientation?: ExifOrientation; orientation?: ExifOrientation;
keepExif?: boolean;
} }
export type GenerateThumbnailOptions = Pick<ImageOptions, 'format' | 'quality'> & DecodeToBufferOptions; export type GenerateThumbnailOptions = Pick<ImageOptions, 'format' | 'quality'> & DecodeToBufferOptions;
@ -139,7 +139,7 @@ export interface VideoInterfaces {
export interface IMediaRepository { export interface IMediaRepository {
// image // image
extract(input: string, output: string, withExif?: boolean): Promise<boolean>; extract(input: string, output: string, withExif?: boolean): Promise<boolean>;
cloneExif(input: string, output: string): Promise<boolean>; writeExif(tags: ExifEntity, output: string): Promise<boolean>;
decodeImage(input: string, options: DecodeToBufferOptions): Promise<ImageBuffer>; decodeImage(input: string, options: DecodeToBufferOptions): Promise<ImageBuffer>;
generateThumbnail(input: string, options: GenerateThumbnailOptions, outputFile: string): Promise<void>; generateThumbnail(input: string, options: GenerateThumbnailOptions, outputFile: string): Promise<void>;
generateThumbnail(input: Buffer, options: GenerateThumbnailFromBufferOptions, outputFile: string): Promise<void>; generateThumbnail(input: Buffer, options: GenerateThumbnailFromBufferOptions, outputFile: string): Promise<void>;

View file

@ -1,19 +1,20 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { BinaryField, exiftool } from 'exiftool-vendored'; import { ExifDateTime, exiftool, WriteTags } from 'exiftool-vendored';
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
import { Duration } from 'luxon'; import { Duration } from 'luxon';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { Writable } from 'node:stream'; import { Writable } from 'node:stream';
import sharp from 'sharp'; import sharp from 'sharp';
import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants'; import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants';
import { ExifEntity } from 'src/entities/exif.entity';
import { Colorspace, LogLevel } from 'src/enum'; import { Colorspace, LogLevel } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { import {
DecodeToBufferOptions, DecodeToBufferOptions,
GenerateThumbhashOptions, GenerateThumbhashOptions,
GenerateThumbnailOptions, GenerateThumbnailOptions,
IMediaRepository,
ImageDimensions, ImageDimensions,
IMediaRepository,
ProbeOptions, ProbeOptions,
TranscodeCommand, TranscodeCommand,
VideoInfo, VideoInfo,
@ -62,40 +63,40 @@ export class MediaRepository implements IMediaRepository {
return true; return true;
} }
async cloneExif(input: string, output: string): Promise<boolean> { async writeExif(tags: ExifEntity, output: string): Promise<boolean> {
try { try {
// exclude some non-tag fields that interfere with writing back to the image const tagsToWrite: WriteTags = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars ExifImageWidth: tags.exifImageWidth,
const { errors, warnings, OriginalFileName, FileName, Directory, ...exifTags } = await exiftool.read(input, { ExifImageHeight: tags.exifImageHeight,
ignoreMinorErrors: true, DateTimeOriginal: tags.dateTimeOriginal && ExifDateTime.fromMillis(tags.dateTimeOriginal.getTime()),
}); ModifyDate: tags.modifyDate && ExifDateTime.fromMillis(tags.modifyDate.getTime()),
this.logger.debug('Read exif data from original image:', exifTags); TimeZone: tags.timeZone,
if (errors?.length) { GPSLatitude: tags.latitude,
this.logger.debug('Error reading exif data', JSON.stringify(errors)); GPSLongitude: tags.longitude,
} ProjectionType: tags.projectionType,
if (warnings?.length) { City: tags.city,
this.logger.debug('Warning reading exif data', JSON.stringify(warnings)); Country: tags.country,
} Make: tags.make,
// filter out binary fields as they emit errors like: Model: tags.model,
// Could not extract exif data from image: cannot encode {"_ctor":"BinaryField","bytes":4815633,"rawValue":"(Binary data 4815633 bytes, use -b option to extract)"} LensModel: tags.lensModel,
const exifTagsToWrite = Object.fromEntries( Fnumber: tags.fNumber?.toFixed(1),
Object.entries(exifTags).filter(([, value]) => !(value instanceof BinaryField)), FocalLength: tags.focalLength?.toFixed(1),
); ISO: tags.iso,
// GOTCHA: "Orientation" is read as a number by default, ExposureTime: tags.exposureTime,
// but when writing back, it has to be keyed "Orientation#" ProfileDescription: tags.profileDescription,
// @see https://github.com/photostructure/exiftool-vendored.js/blob/f77b0f097fb26b68326d325caaf1642cf29cfe3d/src/WriteTags.ts#L22 ColorSpace: tags.colorspace,
if (exifTags.Orientation != null) { Rating: tags.rating,
exifTagsToWrite['Orientation#'] = exifTags.Orientation; // specially convert Orientation to numeric Orientation# for exiftool
delete exifTagsToWrite['Orientation']; 'Orientation#': tags.orientation ? Number(tags.orientation) : undefined,
} };
const result = await exiftool.write(output, exifTagsToWrite, {
await exiftool.write(output, tagsToWrite, {
ignoreMinorErrors: true, ignoreMinorErrors: true,
writeArgs: ['-overwrite_original'], writeArgs: ['-overwrite_original'],
}); });
this.logger.debug('Wrote exif data to extracted image:', result);
return true; return true;
} catch (error: any) { } catch (error: any) {
this.logger.warn(`Could not extract exif data from image: ${error.message}`); this.logger.warn(`Could not write exif data to image: ${error.message}`);
return false; return false;
} }
} }
@ -105,17 +106,13 @@ export class MediaRepository implements IMediaRepository {
} }
async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise<void> { async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise<void> {
let pipeline = this.getImageDecodingPipeline(input, options).toFormat(options.format, { await this.getImageDecodingPipeline(input, options)
quality: options.quality, .toFormat(options.format, {
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp quality: options.quality,
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0', // 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) { .toFile(output);
pipeline = pipeline.keepExif();
}
await pipeline.toFile(output);
} }
private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) { private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {

View file

@ -246,7 +246,7 @@ export class MediaService extends BaseService {
let useExtracted = false; let useExtracted = false;
let decodeInputPath: string = asset.originalPath; let decodeInputPath: string = asset.originalPath;
// Converted or extracted image from non-web-supported formats (e.g. RAW) // Converted or extracted image from non-web-supported formats (e.g. RAW)
let fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, image.preview.format); let fullsizePath: string | undefined;
if (shouldConvertFullsize) { if (shouldConvertFullsize) {
// unset size to decode fullsize image // unset size to decode fullsize image
decodeOptions.size = undefined; decodeOptions.size = undefined;
@ -258,7 +258,7 @@ export class MediaService extends BaseService {
// Assume extracted image from RAW always in JPEG format, as implied from the `jpgFromRaw` tag name // Assume extracted image from RAW always in JPEG format, as implied from the `jpgFromRaw` tag name
const extractedPath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, ImageFormat.JPEG); const extractedPath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, ImageFormat.JPEG);
const didExtract = await this.mediaRepository.extract(asset.originalPath, extractedPath, true); const didExtract = await this.mediaRepository.extract(asset.originalPath, extractedPath, true);
useExtracted = didExtract && (await this.shouldUseExtractedImage(fullsizePath, image.preview.size)); useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size));
if (useExtracted) { if (useExtracted) {
if (shouldConvertFullsize) { if (shouldConvertFullsize) {
@ -268,9 +268,12 @@ export class MediaService extends BaseService {
} }
// use this as origin of preview and thumbnail // use this as origin of preview and thumbnail
decodeInputPath = extractedPath; decodeInputPath = extractedPath;
// clone EXIF to persist orientation and other metadata if (asset.exifInfo) {
// this is delayed to reduce I/O overhead as we cannot do it in one go with extraction due to exiftool limitations // write EXIF, especially orientation and colorspace essential for subsequent processing
await this.mediaRepository.cloneExif(asset.originalPath, extractedPath); await this.mediaRepository.writeExif(asset.exifInfo, extractedPath);
}
} else {
fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, image.preview.format);
} }
} }
@ -281,11 +284,11 @@ export class MediaService extends BaseService {
this.mediaRepository.generateThumbhash(data, thumbnailOptions), this.mediaRepository.generateThumbhash(data, thumbnailOptions),
this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailPath), this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailPath),
this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewPath), this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewPath),
shouldConvertFullsize && fullsizePath &&
!useExtracted && // did not extract a usable image from RAW !useExtracted && // did not extract a usable image from RAW
this.mediaRepository.generateThumbnail( this.mediaRepository.generateThumbnail(
data, data,
{ ...image.preview, ...thumbnailOptions, size: undefined, keepExif: true }, { ...image.preview, ...thumbnailOptions, size: undefined },
fullsizePath, fullsizePath,
), ),
]); ]);