Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 11 commits into from
Nov 8, 2024
17 changes: 17 additions & 0 deletions server/src/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -81,3 +82,19 @@ export const CLIP_MODEL_INFO: Record<string, ModelInfo> = {
'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, 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;
11 changes: 11 additions & 0 deletions server/src/enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
3 changes: 2 additions & 1 deletion server/src/interfaces/media.interface.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -31,6 +31,7 @@ interface DecodeImageOptions {

export interface DecodeToBufferOptions extends DecodeImageOptions {
size: number;
orientation?: ExifOrientation;
}

export type GenerateThumbnailOptions = ImageOptions & DecodeImageOptions;
Expand Down
11 changes: 10 additions & 1 deletion server/src/repositories/media.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion server/src/services/media.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
6 changes: 3 additions & 3 deletions server/src/services/metadata.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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() }),
);
});

Expand Down
21 changes: 5 additions & 16 deletions server/src/services/metadata.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -36,17 +36,6 @@ const EXIF_DATE_TAGS: Array<keyof Tags> = [
'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 => {
// handle lists of numbers
if (Array.isArray(value)) {
Expand Down Expand Up @@ -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;
}
}
Expand Down
Loading