mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	fix(server): thumbnail rotation when using embedded previews (#13948)
This commit is contained in:
		
							parent
							
								
									7534098596
								
							
						
					
					
						commit
						c8b46802d6
					
				@ -1,6 +1,7 @@
 | 
				
			|||||||
import { Duration } from 'luxon';
 | 
					import { Duration } from 'luxon';
 | 
				
			||||||
import { readFileSync } from 'node:fs';
 | 
					import { readFileSync } from 'node:fs';
 | 
				
			||||||
import { SemVer } from 'semver';
 | 
					import { SemVer } from 'semver';
 | 
				
			||||||
 | 
					import { ExifOrientation } from 'src/enum';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const POSTGRES_VERSION_RANGE = '>=14.0.0';
 | 
					export const POSTGRES_VERSION_RANGE = '>=14.0.0';
 | 
				
			||||||
export const VECTORS_VERSION_RANGE = '>=0.2 <0.4';
 | 
					export const VECTORS_VERSION_RANGE = '>=0.2 <0.4';
 | 
				
			||||||
@ -81,3 +82,19 @@ export const CLIP_MODEL_INFO: Record<string, ModelInfo> = {
 | 
				
			|||||||
  'nllb-clip-large-siglip__mrl': { dimSize: 1152 },
 | 
					  'nllb-clip-large-siglip__mrl': { dimSize: 1152 },
 | 
				
			||||||
  'nllb-clip-large-siglip__v1': { 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, SharpRotationData> = {
 | 
				
			||||||
 | 
					  [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;
 | 
				
			||||||
 | 
				
			|||||||
@ -373,3 +373,14 @@ export enum ImmichTelemetry {
 | 
				
			|||||||
  REPO = 'repo',
 | 
					  REPO = 'repo',
 | 
				
			||||||
  JOB = 'job',
 | 
					  JOB = 'job',
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export enum ExifOrientation {
 | 
				
			||||||
 | 
					  Horizontal = 1,
 | 
				
			||||||
 | 
					  MirrorHorizontal = 2,
 | 
				
			||||||
 | 
					  Rotate180 = 3,
 | 
				
			||||||
 | 
					  MirrorVertical = 4,
 | 
				
			||||||
 | 
					  MirrorHorizontalRotate270CW = 5,
 | 
				
			||||||
 | 
					  Rotate90CW = 6,
 | 
				
			||||||
 | 
					  MirrorHorizontalRotate90CW = 7,
 | 
				
			||||||
 | 
					  Rotate270CW = 8,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,5 @@
 | 
				
			|||||||
import { Writable } from 'node:stream';
 | 
					import { Writable } from 'node:stream';
 | 
				
			||||||
import { ImageFormat, TranscodeTarget, VideoCodec } from 'src/enum';
 | 
					import { ExifOrientation, ImageFormat, TranscodeTarget, VideoCodec } from 'src/enum';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const IMediaRepository = 'IMediaRepository';
 | 
					export const IMediaRepository = 'IMediaRepository';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -31,6 +31,7 @@ interface DecodeImageOptions {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export interface DecodeToBufferOptions extends DecodeImageOptions {
 | 
					export interface DecodeToBufferOptions extends DecodeImageOptions {
 | 
				
			||||||
  size: number;
 | 
					  size: number;
 | 
				
			||||||
 | 
					  orientation?: ExifOrientation;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type GenerateThumbnailOptions = ImageOptions & DecodeImageOptions;
 | 
					export type GenerateThumbnailOptions = ImageOptions & DecodeImageOptions;
 | 
				
			||||||
 | 
				
			|||||||
@ -5,6 +5,7 @@ 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 { 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 {
 | 
				
			||||||
@ -82,7 +83,15 @@ export class MediaRepository implements IMediaRepository {
 | 
				
			|||||||
      .withIccProfile(options.colorspace);
 | 
					      .withIccProfile(options.colorspace);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!options.raw) {
 | 
					    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) {
 | 
					    if (options.crop) {
 | 
				
			||||||
 | 
				
			|||||||
@ -214,7 +214,8 @@ export class MediaService extends BaseService {
 | 
				
			|||||||
      const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace;
 | 
					      const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace;
 | 
				
			||||||
      const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true';
 | 
					      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 { data, info } = await this.mediaRepository.decodeImage(inputPath, decodeOptions);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const options = { colorspace, processInvalidImages, raw: info };
 | 
					      const options = { colorspace, processInvalidImages, raw: info };
 | 
				
			||||||
 | 
				
			|||||||
@ -3,7 +3,7 @@ import { randomBytes } from 'node:crypto';
 | 
				
			|||||||
import { Stats } from 'node:fs';
 | 
					import { Stats } from 'node:fs';
 | 
				
			||||||
import { constants } from 'node:fs/promises';
 | 
					import { constants } from 'node:fs/promises';
 | 
				
			||||||
import { ExifEntity } from 'src/entities/exif.entity';
 | 
					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 { IAlbumRepository } from 'src/interfaces/album.interface';
 | 
				
			||||||
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
 | 
					import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
 | 
				
			||||||
import { IConfigRepository } from 'src/interfaces/config.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 { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
 | 
				
			||||||
import { ITagRepository } from 'src/interfaces/tag.interface';
 | 
					import { ITagRepository } from 'src/interfaces/tag.interface';
 | 
				
			||||||
import { IUserRepository } from 'src/interfaces/user.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 { assetStub } from 'test/fixtures/asset.stub';
 | 
				
			||||||
import { fileStub } from 'test/fixtures/file.stub';
 | 
					import { fileStub } from 'test/fixtures/file.stub';
 | 
				
			||||||
import { probeStub } from 'test/fixtures/media.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.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } });
 | 
				
			||||||
      expect(assetMock.upsertExif).toHaveBeenCalledWith(
 | 
					      expect(assetMock.upsertExif).toHaveBeenCalledWith(
 | 
				
			||||||
        expect.objectContaining({ orientation: Orientation.Rotate270CW.toString() }),
 | 
					        expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }),
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -12,7 +12,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity';
 | 
				
			|||||||
import { AssetEntity } from 'src/entities/asset.entity';
 | 
					import { AssetEntity } from 'src/entities/asset.entity';
 | 
				
			||||||
import { ExifEntity } from 'src/entities/exif.entity';
 | 
					import { ExifEntity } from 'src/entities/exif.entity';
 | 
				
			||||||
import { PersonEntity } from 'src/entities/person.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 { WithoutProperty } from 'src/interfaces/asset.interface';
 | 
				
			||||||
import { DatabaseLock } from 'src/interfaces/database.interface';
 | 
					import { DatabaseLock } from 'src/interfaces/database.interface';
 | 
				
			||||||
import { ArgOf } from 'src/interfaces/event.interface';
 | 
					import { ArgOf } from 'src/interfaces/event.interface';
 | 
				
			||||||
@ -36,17 +36,6 @@ const EXIF_DATE_TAGS: Array<keyof Tags> = [
 | 
				
			|||||||
  'DateTimeCreated',
 | 
					  'DateTimeCreated',
 | 
				
			||||||
];
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export enum Orientation {
 | 
					 | 
				
			||||||
  Horizontal = 1,
 | 
					 | 
				
			||||||
  MirrorHorizontal = 2,
 | 
					 | 
				
			||||||
  Rotate180 = 3,
 | 
					 | 
				
			||||||
  MirrorVertical = 4,
 | 
					 | 
				
			||||||
  MirrorHorizontalRotate270CW = 5,
 | 
					 | 
				
			||||||
  Rotate90CW = 6,
 | 
					 | 
				
			||||||
  MirrorHorizontalRotate90CW = 7,
 | 
					 | 
				
			||||||
  Rotate270CW = 8,
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const validate = <T>(value: T): NonNullable<T> | null => {
 | 
					const validate = <T>(value: T): NonNullable<T> | null => {
 | 
				
			||||||
  // handle lists of numbers
 | 
					  // handle lists of numbers
 | 
				
			||||||
  if (Array.isArray(value)) {
 | 
					  if (Array.isArray(value)) {
 | 
				
			||||||
@ -676,19 +665,19 @@ export class MetadataService extends BaseService {
 | 
				
			|||||||
    if (videoStreams[0]) {
 | 
					    if (videoStreams[0]) {
 | 
				
			||||||
      switch (videoStreams[0].rotation) {
 | 
					      switch (videoStreams[0].rotation) {
 | 
				
			||||||
        case -90: {
 | 
					        case -90: {
 | 
				
			||||||
          tags.Orientation = Orientation.Rotate90CW;
 | 
					          tags.Orientation = ExifOrientation.Rotate90CW;
 | 
				
			||||||
          break;
 | 
					          break;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        case 0: {
 | 
					        case 0: {
 | 
				
			||||||
          tags.Orientation = Orientation.Horizontal;
 | 
					          tags.Orientation = ExifOrientation.Horizontal;
 | 
				
			||||||
          break;
 | 
					          break;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        case 90: {
 | 
					        case 90: {
 | 
				
			||||||
          tags.Orientation = Orientation.Rotate270CW;
 | 
					          tags.Orientation = ExifOrientation.Rotate270CW;
 | 
				
			||||||
          break;
 | 
					          break;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        case 180: {
 | 
					        case 180: {
 | 
				
			||||||
          tags.Orientation = Orientation.Rotate180;
 | 
					          tags.Orientation = ExifOrientation.Rotate180;
 | 
				
			||||||
          break;
 | 
					          break;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user