mirror of
https://github.com/immich-app/immich.git
synced 2026-03-01 18:19:10 +03:00
feat: original-sized previews for non-web-friendly images (#14446)
* feat(server): extract full-size previews from RAW images * feat(web): load fullsize preview for RAW images when zoomed in * refactor: tweaks for code review * refactor: rename "converted" preview/assets to "fullsize" * feat(web/server): fullsize preview for non-web-friendly images * feat: tweaks for code review * feat(server): require ASSET_DOWNLOAD premission for fullsize previews * test: fix types and interfaces * chore: gen open-api * feat(server): keep only essential exif in fullsize preview * chore: regen openapi * test: revert unnecessary timeout * feat: move full-size preview config to standalone entry * feat(i18n): update en texts * fix: don't return fullsizePath when disabled * test: full-size previews * test(web): full-size previews * chore: make open-api * feat(server): redirect to preview/original URL when fullsize thumbnail not available * fix(server): delete fullsize preview image on thumbnail regen after fullsize preview turned off * refactor(server): AssetRepository.deleteFiles with Kysely * fix(server): type of MediaRepository.writeExif * minor simplification * minor styling changes and condensed wording * simplify * chore: reuild open-api * test(server): fix media.service tests * test(web): fix photo-viewer test * fix(server): use fullsize image when requested * fix file path extension * formatting * use fullsize when zooming back out or when "display original photos" is enabled * simplify condition --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Insertable, Kysely, UpdateResult, Updateable, sql } from 'kysely';
|
||||
import { Insertable, Kysely, Selectable, UpdateResult, Updateable, sql } from 'kysely';
|
||||
import { isEmpty, isUndefined, omitBy } from 'lodash';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db';
|
||||
@@ -1036,6 +1036,17 @@ export class AssetRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteFiles(files: Pick<Selectable<AssetFiles>, 'id'>[]): Promise<void> {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db
|
||||
.deleteFrom('asset_files')
|
||||
.where('id', '=', anyUuid(files.map((file) => file.id)))
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({
|
||||
params: [{ libraryId: DummyValue.UUID, importPaths: [DummyValue.STRING], exclusionPatterns: [DummyValue.STRING] }],
|
||||
})
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { exiftool } from 'exiftool-vendored';
|
||||
import { ExifDateTime, exiftool, WriteTags } from 'exiftool-vendored';
|
||||
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
|
||||
import { Duration } from 'luxon';
|
||||
import fs from 'node:fs/promises';
|
||||
import { Writable } from 'node:stream';
|
||||
import sharp from 'sharp';
|
||||
import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { Colorspace, LogLevel } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import {
|
||||
@@ -43,9 +44,14 @@ export class MediaRepository {
|
||||
|
||||
async extract(input: string, output: string): Promise<boolean> {
|
||||
try {
|
||||
// remove existing output file if it exists
|
||||
// as exiftool-vendored 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);
|
||||
await exiftool.extractBinaryTag('JpgFromRaw2', input, output);
|
||||
} catch {
|
||||
try {
|
||||
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);
|
||||
@@ -57,10 +63,47 @@ export class MediaRepository {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async writeExif(tags: Partial<ExifEntity>, output: string): Promise<boolean> {
|
||||
try {
|
||||
const tagsToWrite: WriteTags = {
|
||||
ExifImageWidth: tags.exifImageWidth,
|
||||
ExifImageHeight: tags.exifImageHeight,
|
||||
DateTimeOriginal: tags.dateTimeOriginal && ExifDateTime.fromMillis(tags.dateTimeOriginal.getTime()),
|
||||
ModifyDate: tags.modifyDate && ExifDateTime.fromMillis(tags.modifyDate.getTime()),
|
||||
TimeZone: tags.timeZone,
|
||||
GPSLatitude: tags.latitude,
|
||||
GPSLongitude: tags.longitude,
|
||||
ProjectionType: tags.projectionType,
|
||||
City: tags.city,
|
||||
Country: tags.country,
|
||||
Make: tags.make,
|
||||
Model: tags.model,
|
||||
LensModel: tags.lensModel,
|
||||
Fnumber: tags.fNumber?.toFixed(1),
|
||||
FocalLength: tags.focalLength?.toFixed(1),
|
||||
ISO: tags.iso,
|
||||
ExposureTime: tags.exposureTime,
|
||||
ProfileDescription: tags.profileDescription,
|
||||
ColorSpace: tags.colorspace,
|
||||
Rating: tags.rating,
|
||||
// specially convert Orientation to numeric Orientation# for exiftool
|
||||
'Orientation#': tags.orientation ? Number(tags.orientation) : undefined,
|
||||
};
|
||||
|
||||
await exiftool.write(output, tagsToWrite, {
|
||||
ignoreMinorErrors: true,
|
||||
writeArgs: ['-overwrite_original'],
|
||||
});
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Could not write exif data to image: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
decodeImage(input: string, options: DecodeToBufferOptions) {
|
||||
return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true });
|
||||
}
|
||||
@@ -101,7 +144,10 @@ export class MediaRepository {
|
||||
pipeline = pipeline.extract(options.crop);
|
||||
}
|
||||
|
||||
return pipeline.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true });
|
||||
if (options.size !== undefined) {
|
||||
pipeline = pipeline.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true });
|
||||
}
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise<Buffer> {
|
||||
|
||||
Reference in New Issue
Block a user