diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index e0281085cf1f0..d816de2aeaea8 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 0185f300fac5b..68972c16d392c 100644 --- a/mobile/openapi/lib/model/exif_response_dto.dart +++ b/mobile/openapi/lib/model/exif_response_dto.dart @@ -71,7 +71,7 @@ class ExifResponseDto { DateTime? modifyDate; - String? orientation; + ExifResponseDtoOrientationEnum? orientation; String? projectionType; @@ -289,7 +289,9 @@ class ExifResponseDto { make: mapValueOfType(json, r'make'), model: mapValueOfType(json, r'model'), modifyDate: mapDateTime(json, r'modifyDate', r''), - orientation: mapValueOfType(json, r'orientation'), + orientation: json[r'orientation'] == null + ? null + : ExifResponseDtoOrientationEnum.parse('${json[r'orientation']}'), projectionType: mapValueOfType(json, r'projectionType'), rating: json[r'rating'] == null ? null @@ -346,3 +348,95 @@ class ExifResponseDto { }; } + +class ExifResponseDtoOrientationEnum { + /// Instantiate a new enum with the provided [value]. + const ExifResponseDtoOrientationEnum._(this.value); + + /// The underlying value of this enum member. + final num value; + + @override + String toString() => value.toString(); + + num toJson() => value; + + static const n1 = ExifResponseDtoOrientationEnum._('1'); + static const n2 = ExifResponseDtoOrientationEnum._('2'); + static const n8 = ExifResponseDtoOrientationEnum._('8'); + static const n7 = ExifResponseDtoOrientationEnum._('7'); + static const n3 = ExifResponseDtoOrientationEnum._('3'); + static const n4 = ExifResponseDtoOrientationEnum._('4'); + static const n6 = ExifResponseDtoOrientationEnum._('6'); + static const n5 = ExifResponseDtoOrientationEnum._('5'); + + /// List of all possible values in this [enum][ExifResponseDtoOrientationEnum]. + static const values = [ + n1, + n2, + n8, + n7, + n3, + n4, + n6, + n5, + ]; + + static ExifResponseDtoOrientationEnum? fromJson(dynamic value) => ExifResponseDtoOrientationEnumTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ExifResponseDtoOrientationEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [ExifResponseDtoOrientationEnum] to num, +/// and [decode] dynamic data back to [ExifResponseDtoOrientationEnum]. +class ExifResponseDtoOrientationEnumTypeTransformer { + factory ExifResponseDtoOrientationEnumTypeTransformer() => _instance ??= const ExifResponseDtoOrientationEnumTypeTransformer._(); + + const ExifResponseDtoOrientationEnumTypeTransformer._(); + + num encode(ExifResponseDtoOrientationEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a ExifResponseDtoOrientationEnum. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + ExifResponseDtoOrientationEnum? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case '1': return ExifResponseDtoOrientationEnum.n1; + case '2': return ExifResponseDtoOrientationEnum.n2; + case '8': return ExifResponseDtoOrientationEnum.n8; + case '7': return ExifResponseDtoOrientationEnum.n7; + case '3': return ExifResponseDtoOrientationEnum.n3; + case '4': return ExifResponseDtoOrientationEnum.n4; + case '6': return ExifResponseDtoOrientationEnum.n6; + case '5': return ExifResponseDtoOrientationEnum.n5; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [ExifResponseDtoOrientationEnumTypeTransformer] instance. + static ExifResponseDtoOrientationEnumTypeTransformer? _instance; +} + + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f48fa989dad57..f48517f74c634 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 c2d73bda1acaf..ad73c220c2c31 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 77be18f0e76a6..c4f2c2f9cda53 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 079891ae56cb7..8494befe0d7db 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 c9c29d732a3d9..fd8e61a2d4724 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 027b3160a7c32..2977216a58015 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 0000000000000..67768b4441403 --- /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 19aaa2ea1a323..c697ce4a1f1e0 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 eaa491c3ee7d8..0743a33ea1edc 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 f237e1dea942c..783459e54911c 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 84a896452f7f1..6f467af765560 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; -} - -function isRotated270CW(orientation: number) { - return orientation === 7 || orientation === 8 || orientation === -90; -} +export function isFlipped(orientation?: LameGeneratedOrientation | null) { + if (!orientation) { + return false; + } -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 {