1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-28 09:33:27 +02:00

fix(server): thumbnail rotation when using embedded previews (#13948)

This commit is contained in:
Terry Zhao 2024-11-08 01:30:59 -05:00 committed by GitHub
parent 7534098596
commit c8b46802d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 50 additions and 22 deletions

View File

@ -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;

View File

@ -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,
}

View File

@ -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;

View File

@ -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) {

View File

@ -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 };

View File

@ -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() }),
); );
}); });

View File

@ -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;
} }
} }