From c8b46802d6f94f8cf04b8b66ed563e5692b56d92 Mon Sep 17 00:00:00 2001 From: Terry Zhao Date: Fri, 8 Nov 2024 01:30:59 -0500 Subject: [PATCH] fix(server): thumbnail rotation when using embedded previews (#13948) --- server/src/constants.ts | 17 ++++++++++++++++ server/src/enum.ts | 11 ++++++++++ server/src/interfaces/media.interface.ts | 3 ++- server/src/repositories/media.repository.ts | 11 +++++++++- server/src/services/media.service.ts | 3 ++- server/src/services/metadata.service.spec.ts | 6 +++--- server/src/services/metadata.service.ts | 21 +++++--------------- 7 files changed, 50 insertions(+), 22 deletions(-) diff --git a/server/src/constants.ts b/server/src/constants.ts index 0b9f525c71..fc2442130e 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -1,6 +1,7 @@ import { Duration } from 'luxon'; import { readFileSync } from 'node:fs'; import { SemVer } from 'semver'; +import { ExifOrientation } from 'src/enum'; export const POSTGRES_VERSION_RANGE = '>=14.0.0'; export const VECTORS_VERSION_RANGE = '>=0.2 <0.4'; @@ -81,3 +82,19 @@ export const CLIP_MODEL_INFO: Record = { 'nllb-clip-large-siglip__mrl': { dimSize: 1152 }, 'nllb-clip-large-siglip__v1': { dimSize: 1152 }, }; + +type SharpRotationData = { + angle?: number; + flip?: boolean; + flop?: boolean; +}; +export const ORIENTATION_TO_SHARP_ROTATION: Record = { + [ExifOrientation.Horizontal]: { angle: 0 }, + [ExifOrientation.MirrorHorizontal]: { angle: 0, flop: true }, + [ExifOrientation.Rotate180]: { angle: 180 }, + [ExifOrientation.MirrorVertical]: { angle: 180, flop: true }, + [ExifOrientation.MirrorHorizontalRotate270CW]: { angle: 270, flip: true }, + [ExifOrientation.Rotate90CW]: { angle: 90 }, + [ExifOrientation.MirrorHorizontalRotate90CW]: { angle: 90, flip: true }, + [ExifOrientation.Rotate270CW]: { angle: 270 }, +} as const; diff --git a/server/src/enum.ts b/server/src/enum.ts index 0b82185285..3440d45cee 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -373,3 +373,14 @@ export enum ImmichTelemetry { REPO = 'repo', JOB = 'job', } + +export enum ExifOrientation { + Horizontal = 1, + MirrorHorizontal = 2, + Rotate180 = 3, + MirrorVertical = 4, + MirrorHorizontalRotate270CW = 5, + Rotate90CW = 6, + MirrorHorizontalRotate90CW = 7, + Rotate270CW = 8, +} diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index d8d7395ea7..fe11d17b5f 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -1,5 +1,5 @@ import { Writable } from 'node:stream'; -import { ImageFormat, TranscodeTarget, VideoCodec } from 'src/enum'; +import { ExifOrientation, ImageFormat, TranscodeTarget, VideoCodec } from 'src/enum'; export const IMediaRepository = 'IMediaRepository'; @@ -31,6 +31,7 @@ interface DecodeImageOptions { export interface DecodeToBufferOptions extends DecodeImageOptions { size: number; + orientation?: ExifOrientation; } export type GenerateThumbnailOptions = ImageOptions & DecodeImageOptions; diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index f38e150c55..8dcbf208c6 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -5,6 +5,7 @@ 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 { Colorspace, LogLevel } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { @@ -82,7 +83,15 @@ export class MediaRepository implements IMediaRepository { .withIccProfile(options.colorspace); if (!options.raw) { - pipeline = pipeline.rotate(); + const { angle, flip, flop } = options.orientation ? ORIENTATION_TO_SHARP_ROTATION[options.orientation] : {}; + pipeline = pipeline.rotate(angle); + if (flip) { + pipeline = pipeline.flip(); + } + + if (flop) { + pipeline = pipeline.flop(); + } } if (options.crop) { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 9058d08ff6..c7b3dbced1 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -214,7 +214,8 @@ export class MediaService extends BaseService { const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true'; - const decodeOptions = { colorspace, processInvalidImages, size: image.preview.size }; + const 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 }; diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 986fab48bd..e3dc19111b 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -3,7 +3,7 @@ import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; import { ExifEntity } from 'src/entities/exif.entity'; -import { AssetType, ImmichWorker, SourceType } from 'src/enum'; +import { AssetType, ExifOrientation, ImmichWorker, SourceType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IConfigRepository } from 'src/interfaces/config.interface'; @@ -18,7 +18,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; -import { MetadataService, Orientation } from 'src/services/metadata.service'; +import { MetadataService } from 'src/services/metadata.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { probeStub } from 'test/fixtures/media.stub'; @@ -539,7 +539,7 @@ describe(MetadataService.name, () => { expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalledWith( - expect.objectContaining({ orientation: Orientation.Rotate270CW.toString() }), + expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }), ); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 98566b9693..d8da095abf 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -12,7 +12,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { PersonEntity } from 'src/entities/person.entity'; -import { AssetType, ImmichWorker, SourceType } from 'src/enum'; +import { AssetType, ExifOrientation, ImmichWorker, SourceType } from 'src/enum'; import { WithoutProperty } from 'src/interfaces/asset.interface'; import { DatabaseLock } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; @@ -36,17 +36,6 @@ const EXIF_DATE_TAGS: Array = [ 'DateTimeCreated', ]; -export enum Orientation { - Horizontal = 1, - MirrorHorizontal = 2, - Rotate180 = 3, - MirrorVertical = 4, - MirrorHorizontalRotate270CW = 5, - Rotate90CW = 6, - MirrorHorizontalRotate90CW = 7, - Rotate270CW = 8, -} - const validate = (value: T): NonNullable | null => { // handle lists of numbers if (Array.isArray(value)) { @@ -676,19 +665,19 @@ export class MetadataService extends BaseService { if (videoStreams[0]) { switch (videoStreams[0].rotation) { case -90: { - tags.Orientation = Orientation.Rotate90CW; + tags.Orientation = ExifOrientation.Rotate90CW; break; } case 0: { - tags.Orientation = Orientation.Horizontal; + tags.Orientation = ExifOrientation.Horizontal; break; } case 90: { - tags.Orientation = Orientation.Rotate270CW; + tags.Orientation = ExifOrientation.Rotate270CW; break; } case 180: { - tags.Orientation = Orientation.Rotate180; + tags.Orientation = ExifOrientation.Rotate180; break; } }