diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index e0281085cf..d816de2aea 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -1052,7 +1052,7 @@ describe('/asset', () => { dateTimeOriginal: '2010-07-20T17:27:12.000Z', latitude: null, longitude: null, - orientation: '1', + orientation: 1, }, }, }, diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index 0185f300fa..68972c16d3 100644 Binary files a/mobile/openapi/lib/model/exif_response_dto.dart and b/mobile/openapi/lib/model/exif_response_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f48fa989da..f48517f74c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9074,8 +9074,18 @@ }, "orientation": { "default": null, + "enum": [ + 1, + 2, + 8, + 7, + 3, + 4, + 6, + 5 + ], "nullable": true, - "type": "string" + "type": "number" }, "projectionType": { "default": null, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c2d73bda1a..ad73c220c2 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -195,7 +195,7 @@ export type ExifResponseDto = { make?: string | null; model?: string | null; modifyDate?: string | null; - orientation?: string | null; + orientation?: Orientation | null; projectionType?: string | null; rating?: number | null; state?: string | null; @@ -3266,6 +3266,16 @@ export enum AlbumUserRole { Editor = "editor", Viewer = "viewer" } +export enum Orientation { + $1 = 1, + $2 = 2, + $8 = 8, + $7 = 7, + $3 = 3, + $4 = 4, + $6 = 6, + $5 = 5 +} export enum SourceType { MachineLearning = "machine-learning", Exif = "exif" diff --git a/open-api/typescript-sdk/src/index.ts b/open-api/typescript-sdk/src/index.ts index 77be18f0e7..c4f2c2f9cd 100644 --- a/open-api/typescript-sdk/src/index.ts +++ b/open-api/typescript-sdk/src/index.ts @@ -1,4 +1,5 @@ import { defaults } from './fetch-client.js'; +export { Orientation as LameGeneratedOrientation } from './fetch-client.js'; export * from './fetch-client.js'; export * from './fetch-errors.js'; @@ -8,6 +9,17 @@ export interface InitOptions { apiKey: string; } +export enum Orientation { + Rotate0 = 1, + Rotate0Mirrored = 2, + Rotate90 = 8, + Rotate90Mirrored = 7, + Rotate180 = 3, + Rotate180Mirrored = 4, + Rotate270 = 6, + Rotate270Mirrored = 5, +} + export const init = ({ baseUrl, apiKey }: InitOptions) => { setBaseUrl(baseUrl); setApiKey(apiKey); diff --git a/server/src/dtos/exif.dto.ts b/server/src/dtos/exif.dto.ts index 079891ae56..8494befe0d 100644 --- a/server/src/dtos/exif.dto.ts +++ b/server/src/dtos/exif.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { ExifEntity } from 'src/entities/exif.entity'; +import { Orientation } from 'src/enum'; export class ExifResponseDto { make?: string | null = null; @@ -9,7 +10,9 @@ export class ExifResponseDto { @ApiProperty({ type: 'integer', format: 'int64' }) fileSizeInByte?: number | null = null; - orientation?: string | null = null; + + @ApiProperty({ enum: Orientation }) + orientation?: Orientation | null = null; dateTimeOriginal?: Date | null = null; modifyDate?: Date | null = null; timeZone?: string | null = null; diff --git a/server/src/entities/exif.entity.ts b/server/src/entities/exif.entity.ts index c9c29d732a..fd8e61a2d4 100644 --- a/server/src/entities/exif.entity.ts +++ b/server/src/entities/exif.entity.ts @@ -1,4 +1,5 @@ import { AssetEntity } from 'src/entities/asset.entity'; +import { Orientation } from 'src/enum'; import { Index, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; import { Column } from 'typeorm/decorator/columns/Column.js'; import { Entity } from 'typeorm/decorator/entity/Entity.js'; @@ -25,8 +26,8 @@ export class ExifEntity { @Column({ type: 'bigint', nullable: true }) fileSizeInByte!: number | null; - @Column({ type: 'varchar', nullable: true }) - orientation!: string | null; + @Column({ type: 'enum', enum: Orientation, nullable: true }) + orientation!: Orientation | null; @Column({ type: 'timestamptz', nullable: true }) dateTimeOriginal!: Date | null; diff --git a/server/src/enum.ts b/server/src/enum.ts index 027b3160a7..2977216a58 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -198,3 +198,14 @@ export enum ManualJobName { TAG_CLEANUP = 'tag-cleanup', USER_CLEANUP = 'user-cleanup', } + +export enum Orientation { + Rotate0 = 1, + Rotate0Mirrored = 2, + Rotate90 = 8, + Rotate90Mirrored = 7, + Rotate180 = 3, + Rotate180Mirrored = 4, + Rotate270 = 6, + Rotate270Mirrored = 5, +} diff --git a/server/src/migrations/1726754669860-AddOrientationEnum.ts b/server/src/migrations/1726754669860-AddOrientationEnum.ts new file mode 100644 index 0000000000..67768b4441 --- /dev/null +++ b/server/src/migrations/1726754669860-AddOrientationEnum.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddOrientationEnum1726754669860 implements MigrationInterface { + name = 'AddOrientationEnum1726754669860' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "exif_orientation_enum" AS ENUM('1', '2', '3', '4', '5', '6', '7', '8')`) + await queryRunner.query(` + UPDATE "exif" SET "orientation" = CASE + WHEN "orientation" = '0' THEN '1' + WHEN "orientation" = '1' THEN '1' + WHEN "orientation" = '2' THEN '2' + WHEN "orientation" = '3' THEN '3' + WHEN "orientation" = '4' THEN '4' + WHEN "orientation" = '5' THEN '5' + WHEN "orientation" = '6' THEN '6' + WHEN "orientation" = '7' THEN '7' + WHEN "orientation" = '8' THEN '8' + WHEN "orientation" = '-90' THEN '6' + WHEN "orientation" = '90' THEN '8' + WHEN "orientation" = '180' THEN '3' + ELSE NULL + END`); + await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "orientation" TYPE "exif_orientation_enum" USING "orientation"::"exif_orientation_enum"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "orientation" TYPE character varying USING "orientation"::text`); + await queryRunner.query(`DROP TYPE "exif_orientation_enum"`); + } +} diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 19aaa2ea1a..c697ce4a1f 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, SourceType } from 'src/enum'; +import { AssetType, Orientation, SourceType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -20,7 +20,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'; @@ -537,9 +537,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]); - expect(assetMock.upsertExif).toHaveBeenCalledWith( - expect.objectContaining({ orientation: Orientation.Rotate270CW.toString() }), - ); + expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ orientation: Orientation.Rotate90 })); }); it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => { @@ -786,7 +784,7 @@ describe(MetadataService.name, () => { Make: 'test-factory', Model: "'mockel'", ModifyDate: ExifDateTime.fromISO(dateForTest.toISOString()), - Orientation: 0, + Orientation: 1, ProfileDescription: 'extensive description', ProjectionType: 'equirectangular', tz: 'UTC-11:30', @@ -819,7 +817,7 @@ describe(MetadataService.name, () => { make: tags.Make, model: tags.Model, modifyDate: expect.any(Date), - orientation: tags.Orientation?.toString(), + orientation: 1, profileDescription: tags.ProfileDescription, projectionType: 'EQUIRECTANGULAR', timeZone: tags.tz, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index eaa491c3ee..0743a33ea1 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -12,7 +12,7 @@ import { OnEmit } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; -import { AssetType, SourceType } from 'src/enum'; +import { AssetType, Orientation, SourceType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -54,17 +54,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)) { @@ -243,7 +232,7 @@ export class MetadataService { fileSizeInByte: stats.size, exifImageHeight: validate(exifTags.ImageHeight), exifImageWidth: validate(exifTags.ImageWidth), - orientation: validate(exifTags.Orientation)?.toString() ?? null, + orientation: this.getOrientation(asset.id, exifTags.Orientation), projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null, bitsPerSample: this.getBitsPerSample(exifTags), colorspace: exifTags.ColorSpace ?? null, @@ -669,9 +658,32 @@ export class MetadataService { return tags.BurstID ?? tags.BurstUUID ?? tags.CameraBurstID ?? tags.MediaUniqueID ?? null; } + private getOrientation(id: string, orientation: ImmichTags['Orientation']) { + if (!orientation) { + return; + } + + switch (orientation) { + case Orientation.Rotate0: + case Orientation.Rotate0Mirrored: + case Orientation.Rotate90: + case Orientation.Rotate90Mirrored: + case Orientation.Rotate180: + case Orientation.Rotate180Mirrored: + case Orientation.Rotate270: + case Orientation.Rotate270Mirrored: { + return orientation; + } + } + + this.logger.warn(`Asset ${id} has unknown orientation: "${orientation}", setting to null`); + + return null; + } + private getBitsPerSample(tags: ImmichTags): number | null { const bitDepthTags = [ - tags.BitsPerSample, + tags.Rotation, tags.ComponentBitDepth, tags.ImagePixelDepth, tags.BitDepth, @@ -695,15 +707,15 @@ export class MetadataService { if (videoStreams[0]) { switch (videoStreams[0].rotation) { case -90: { - tags.Orientation = Orientation.Rotate90CW; + tags.Orientation = Orientation.Rotate270; break; } case 0: { - tags.Orientation = Orientation.Horizontal; + tags.Orientation = Orientation.Rotate0; break; } case 90: { - tags.Orientation = Orientation.Rotate270CW; + tags.Orientation = Orientation.Rotate90; break; } case 180: { diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index f237e1dea9..783459e549 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -5,7 +5,7 @@ import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto'; import { mapUser } from 'src/dtos/user.dto'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { UserEntity } from 'src/entities/user.entity'; -import { AssetOrder, AssetStatus, AssetType, SharedLinkType } from 'src/enum'; +import { AssetOrder, AssetStatus, AssetType, Orientation, SharedLinkType } from 'src/enum'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -27,7 +27,7 @@ const assetInfo: ExifResponseDto = { exifImageWidth: 500, exifImageHeight: 500, fileSizeInByte: 100, - orientation: 'orientation', + orientation: Orientation.Rotate0, dateTimeOriginal: today, modifyDate: today, timeZone: 'America/Los_Angeles', @@ -227,7 +227,7 @@ export const sharedLinkStub = { exifImageWidth: 500, exifImageHeight: 500, fileSizeInByte: 100, - orientation: 'orientation', + orientation: Orientation.Rotate0, dateTimeOriginal: today, modifyDate: today, timeZone: 'America/Los_Angeles', diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 84a896452f..6f467af765 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -1,6 +1,6 @@ import { goto } from '$app/navigation'; import FormatBoldMessage from '$lib/components/i18n/format-bold-message.svelte'; -import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification'; +import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; import { AppRoute } from '$lib/constants'; import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; @@ -19,6 +19,8 @@ import { getBaseUrl, getDownloadInfo, getStack, + LameGeneratedOrientation, + Orientation, tagAssets as tagAllAssets, untagAssets, updateAsset, @@ -290,17 +292,17 @@ export function getAssetFilename(asset: AssetResponseDto): string { return `${asset.originalFileName}.${fileExtension}`; } -function isRotated90CW(orientation: number) { - return orientation === 5 || orientation === 6 || orientation === 90; -} +export function isFlipped(orientation?: LameGeneratedOrientation | null) { + if (!orientation) { + return false; + } -function isRotated270CW(orientation: number) { - return orientation === 7 || orientation === 8 || orientation === -90; -} - -export function isFlipped(orientation?: string | null) { - const value = Number(orientation); - return value && (isRotated270CW(value) || isRotated90CW(value)); + return [ + Orientation.Rotate90, + Orientation.Rotate90Mirrored, + Orientation.Rotate270, + Orientation.Rotate270Mirrored, + ].includes(orientation as unknown as Orientation); } export function getFileSize(asset: AssetResponseDto): string {