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:
parent
5c40b52a1c
commit
97b1261095
4 changed files with 51 additions and 51 deletions
|
@ -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 }) => {
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
Loading…
Reference in a new issue