From 9cd0871178ada5f7e17f15883e45931de250f04d Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 13 Feb 2025 17:02:44 -0500 Subject: [PATCH] feat(web): rotate image --- i18n/en.json | 3 + .../lib/model/asset_bulk_update_dto.dart | 105 +++++++++++++++++- .../openapi/lib/model/update_asset_dto.dart | 105 +++++++++++++++++- open-api/immich-openapi-specs.json | 30 +++++ open-api/typescript-sdk/src/fetch-client.ts | 12 ++ server/src/dtos/asset.dto.ts | 8 +- .../src/repositories/metadata.repository.ts | 1 + server/src/services/asset.service.ts | 16 ++- server/src/services/media.service.ts | 2 +- server/src/services/metadata.service.ts | 3 +- server/src/types.ts | 1 + .../components/asset-viewer/actions/action.ts | 1 + .../asset-viewer/actions/rotate-action.svelte | 96 ++++++++++++++++ .../asset-viewer/asset-viewer-nav-bar.svelte | 15 ++- .../asset-viewer/asset-viewer.svelte | 15 +-- .../editor/crop-tool/crop-area.svelte | 2 +- .../asset-viewer/photo-viewer.spec.ts | 10 +- .../asset-viewer/photo-viewer.svelte | 14 +-- .../asset-viewer/video-native-viewer.svelte | 8 +- .../asset-viewer/video-wrapper-viewer.svelte | 6 +- .../assets/thumbnail/thumbnail.svelte | 6 +- .../shared-components/show-shortcuts.svelte | 2 + web/src/lib/constants.ts | 13 +++ web/src/lib/utils.ts | 20 ++-- 24 files changed, 441 insertions(+), 53 deletions(-) create mode 100644 web/src/lib/components/asset-viewer/actions/rotate-action.svelte diff --git a/i18n/en.json b/i18n/en.json index 72559d4502..39fe547ac0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -602,6 +602,8 @@ "enable": "Enable", "enabled": "Enabled", "end_date": "End date", + "rotate_left": "Rotate left", + "rotate_right": "Rotate right", "error": "Error", "error_loading_image": "Error loading image", "error_title": "Error - Something went wrong", @@ -644,6 +646,7 @@ "quota_higher_than_disk_size": "You set a quota higher than the disk size", "repair_unable_to_check_items": "Unable to check {count, select, one {item} other {items}}", "unable_to_add_album_users": "Unable to add users to album", + "unable_to_rotate_image": "Unable to rotate image", "unable_to_add_assets_to_shared_link": "Unable to add assets to shared link", "unable_to_add_comment": "Unable to add comment", "unable_to_add_exclusion_pattern": "Unable to add exclusion pattern", diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index 0b5a2c30d9..6bc9078083 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -20,6 +20,7 @@ class AssetBulkUpdateDto { this.isFavorite, this.latitude, this.longitude, + this.orientation, this.rating, }); @@ -67,6 +68,8 @@ class AssetBulkUpdateDto { /// num? longitude; + AssetBulkUpdateDtoOrientationEnum? orientation; + /// Minimum value: -1 /// Maximum value: 5 /// @@ -86,6 +89,7 @@ class AssetBulkUpdateDto { other.isFavorite == isFavorite && other.latitude == latitude && other.longitude == longitude && + other.orientation == orientation && other.rating == rating; @override @@ -98,10 +102,11 @@ class AssetBulkUpdateDto { (isFavorite == null ? 0 : isFavorite!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) + + (orientation == null ? 0 : orientation!.hashCode) + (rating == null ? 0 : rating!.hashCode); @override - String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating]'; + String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, orientation=$orientation, rating=$rating]'; Map toJson() { final json = {}; @@ -136,6 +141,11 @@ class AssetBulkUpdateDto { } else { // json[r'longitude'] = null; } + if (this.orientation != null) { + json[r'orientation'] = this.orientation; + } else { + // json[r'orientation'] = null; + } if (this.rating != null) { json[r'rating'] = this.rating; } else { @@ -162,6 +172,7 @@ class AssetBulkUpdateDto { isFavorite: mapValueOfType(json, r'isFavorite'), latitude: num.parse('${json[r'latitude']}'), longitude: num.parse('${json[r'longitude']}'), + orientation: AssetBulkUpdateDtoOrientationEnum.fromJson(json[r'orientation']), rating: num.parse('${json[r'rating']}'), ); } @@ -214,3 +225,95 @@ class AssetBulkUpdateDto { }; } + +class AssetBulkUpdateDtoOrientationEnum { + /// Instantiate a new enum with the provided [value]. + const AssetBulkUpdateDtoOrientationEnum._(this.value); + + /// The underlying value of this enum member. + final int value; + + @override + String toString() => value.toString(); + + int toJson() => value; + + static const number1 = AssetBulkUpdateDtoOrientationEnum._(1); + static const number2 = AssetBulkUpdateDtoOrientationEnum._(2); + static const number3 = AssetBulkUpdateDtoOrientationEnum._(3); + static const number4 = AssetBulkUpdateDtoOrientationEnum._(4); + static const number5 = AssetBulkUpdateDtoOrientationEnum._(5); + static const number6 = AssetBulkUpdateDtoOrientationEnum._(6); + static const number7 = AssetBulkUpdateDtoOrientationEnum._(7); + static const number8 = AssetBulkUpdateDtoOrientationEnum._(8); + + /// List of all possible values in this [enum][AssetBulkUpdateDtoOrientationEnum]. + static const values = [ + number1, + number2, + number3, + number4, + number5, + number6, + number7, + number8, + ]; + + static AssetBulkUpdateDtoOrientationEnum? fromJson(dynamic value) => AssetBulkUpdateDtoOrientationEnumTypeTransformer().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 = AssetBulkUpdateDtoOrientationEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [AssetBulkUpdateDtoOrientationEnum] to int, +/// and [decode] dynamic data back to [AssetBulkUpdateDtoOrientationEnum]. +class AssetBulkUpdateDtoOrientationEnumTypeTransformer { + factory AssetBulkUpdateDtoOrientationEnumTypeTransformer() => _instance ??= const AssetBulkUpdateDtoOrientationEnumTypeTransformer._(); + + const AssetBulkUpdateDtoOrientationEnumTypeTransformer._(); + + int encode(AssetBulkUpdateDtoOrientationEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a AssetBulkUpdateDtoOrientationEnum. + /// + /// 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. + AssetBulkUpdateDtoOrientationEnum? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case 1: return AssetBulkUpdateDtoOrientationEnum.number1; + case 2: return AssetBulkUpdateDtoOrientationEnum.number2; + case 3: return AssetBulkUpdateDtoOrientationEnum.number3; + case 4: return AssetBulkUpdateDtoOrientationEnum.number4; + case 5: return AssetBulkUpdateDtoOrientationEnum.number5; + case 6: return AssetBulkUpdateDtoOrientationEnum.number6; + case 7: return AssetBulkUpdateDtoOrientationEnum.number7; + case 8: return AssetBulkUpdateDtoOrientationEnum.number8; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [AssetBulkUpdateDtoOrientationEnumTypeTransformer] instance. + static AssetBulkUpdateDtoOrientationEnumTypeTransformer? _instance; +} + + diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index c6ae6d8e07..b88989465e 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -20,6 +20,7 @@ class UpdateAssetDto { this.latitude, this.livePhotoVideoId, this.longitude, + this.orientation, this.rating, }); @@ -73,6 +74,8 @@ class UpdateAssetDto { /// num? longitude; + UpdateAssetDtoOrientationEnum? orientation; + /// Minimum value: -1 /// Maximum value: 5 /// @@ -92,6 +95,7 @@ class UpdateAssetDto { other.latitude == latitude && other.livePhotoVideoId == livePhotoVideoId && other.longitude == longitude && + other.orientation == orientation && other.rating == rating; @override @@ -104,10 +108,11 @@ class UpdateAssetDto { (latitude == null ? 0 : latitude!.hashCode) + (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) + + (orientation == null ? 0 : orientation!.hashCode) + (rating == null ? 0 : rating!.hashCode); @override - String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, longitude=$longitude, rating=$rating]'; + String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, longitude=$longitude, orientation=$orientation, rating=$rating]'; Map toJson() { final json = {}; @@ -146,6 +151,11 @@ class UpdateAssetDto { } else { // json[r'longitude'] = null; } + if (this.orientation != null) { + json[r'orientation'] = this.orientation; + } else { + // json[r'orientation'] = null; + } if (this.rating != null) { json[r'rating'] = this.rating; } else { @@ -170,6 +180,7 @@ class UpdateAssetDto { latitude: num.parse('${json[r'latitude']}'), livePhotoVideoId: mapValueOfType(json, r'livePhotoVideoId'), longitude: num.parse('${json[r'longitude']}'), + orientation: UpdateAssetDtoOrientationEnum.fromJson(json[r'orientation']), rating: num.parse('${json[r'rating']}'), ); } @@ -221,3 +232,95 @@ class UpdateAssetDto { }; } + +class UpdateAssetDtoOrientationEnum { + /// Instantiate a new enum with the provided [value]. + const UpdateAssetDtoOrientationEnum._(this.value); + + /// The underlying value of this enum member. + final int value; + + @override + String toString() => value.toString(); + + int toJson() => value; + + static const number1 = UpdateAssetDtoOrientationEnum._(1); + static const number2 = UpdateAssetDtoOrientationEnum._(2); + static const number3 = UpdateAssetDtoOrientationEnum._(3); + static const number4 = UpdateAssetDtoOrientationEnum._(4); + static const number5 = UpdateAssetDtoOrientationEnum._(5); + static const number6 = UpdateAssetDtoOrientationEnum._(6); + static const number7 = UpdateAssetDtoOrientationEnum._(7); + static const number8 = UpdateAssetDtoOrientationEnum._(8); + + /// List of all possible values in this [enum][UpdateAssetDtoOrientationEnum]. + static const values = [ + number1, + number2, + number3, + number4, + number5, + number6, + number7, + number8, + ]; + + static UpdateAssetDtoOrientationEnum? fromJson(dynamic value) => UpdateAssetDtoOrientationEnumTypeTransformer().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 = UpdateAssetDtoOrientationEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [UpdateAssetDtoOrientationEnum] to int, +/// and [decode] dynamic data back to [UpdateAssetDtoOrientationEnum]. +class UpdateAssetDtoOrientationEnumTypeTransformer { + factory UpdateAssetDtoOrientationEnumTypeTransformer() => _instance ??= const UpdateAssetDtoOrientationEnumTypeTransformer._(); + + const UpdateAssetDtoOrientationEnumTypeTransformer._(); + + int encode(UpdateAssetDtoOrientationEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a UpdateAssetDtoOrientationEnum. + /// + /// 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. + UpdateAssetDtoOrientationEnum? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case 1: return UpdateAssetDtoOrientationEnum.number1; + case 2: return UpdateAssetDtoOrientationEnum.number2; + case 3: return UpdateAssetDtoOrientationEnum.number3; + case 4: return UpdateAssetDtoOrientationEnum.number4; + case 5: return UpdateAssetDtoOrientationEnum.number5; + case 6: return UpdateAssetDtoOrientationEnum.number6; + case 7: return UpdateAssetDtoOrientationEnum.number7; + case 8: return UpdateAssetDtoOrientationEnum.number8; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [UpdateAssetDtoOrientationEnumTypeTransformer] instance. + static UpdateAssetDtoOrientationEnumTypeTransformer? _instance; +} + + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 25d649e195..0cf90cd8ca 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7963,6 +7963,21 @@ "longitude": { "type": "number" }, + "orientation": { + "enum": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ], + "maximum": 8, + "minimum": 1, + "type": "integer" + }, "rating": { "maximum": 5, "minimum": -1, @@ -12880,6 +12895,21 @@ "longitude": { "type": "number" }, + "orientation": { + "enum": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ], + "maximum": 8, + "minimum": 1, + "type": "integer" + }, "rating": { "maximum": 5, "minimum": -1, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 0473b5603b..c0eac5a971 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -391,6 +391,7 @@ export type AssetBulkUpdateDto = { isFavorite?: boolean; latitude?: number; longitude?: number; + orientation?: Orientation; rating?: number; }; export type AssetBulkUploadCheckItem = { @@ -439,6 +440,7 @@ export type UpdateAssetDto = { latitude?: number; livePhotoVideoId?: string | null; longitude?: number; + orientation?: Orientation; rating?: number; }; export type AssetMediaReplaceDto = { @@ -3481,6 +3483,16 @@ export enum AssetMediaStatus { Replaced = "replaced", Duplicate = "duplicate" } +export enum Orientation { + $1 = 1, + $2 = 2, + $3 = 3, + $4 = 4, + $5 = 5, + $6 = 6, + $7 = 7, + $8 = 8 +} export enum Action { Accept = "accept", Reject = "reject" diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 32b14055d5..193377ae86 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -14,7 +14,7 @@ import { ValidateIf, } from 'class-validator'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetType } from 'src/enum'; +import { AssetType, ExifOrientation } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; @@ -54,6 +54,12 @@ export class UpdateAssetBase { @Max(5) @Min(-1) rating?: number; + + @Optional() + @Min(1) + @Max(8) + @ApiProperty({ type: 'integer' }) + orientation?: ExifOrientation; } export class AssetBulkUpdateDto extends UpdateAssetBase { diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 3f297d709b..ae7be5e5f4 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -101,6 +101,7 @@ export class MetadataRepository { } async writeTags(path: string, tags: Partial): Promise { + this.logger.verbose(`Writing tags ${JSON.stringify(tags)} to ${path}`); try { await this.exiftool.write(path, tags); } catch (error) { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index a9b723c9f9..0630fccc6c 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -100,7 +100,7 @@ export class AssetService extends BaseService { async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: [id] }); - const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; + const { description, dateTimeOriginal, latitude, longitude, rating, orientation, ...rest } = dto; const repos = { asset: this.assetRepository, event: this.eventRepository }; let previousMotion: AssetEntity | null = null; @@ -113,7 +113,7 @@ export class AssetService extends BaseService { } } - await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); + await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating, orientation }); const asset = await this.assetRepository.update({ id, ...rest }); @@ -129,11 +129,12 @@ export class AssetService extends BaseService { } async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise { - const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto; + const { ids, dateTimeOriginal, latitude, longitude, orientation, ...options } = dto; await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids }); + // TODO rewrite this to support batching for (const id of ids) { - await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); + await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude, orientation }); } if ( @@ -284,11 +285,14 @@ export class AssetService extends BaseService { } private async updateMetadata(dto: ISidecarWriteJob) { - const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; - const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); + const { id, description, dateTimeOriginal, latitude, longitude, rating, orientation } = dto; + const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating, orientation }, _.isUndefined); if (Object.keys(writes).length > 0) { await this.assetRepository.upsertExif({ assetId: id, ...writes }); await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } }); + if (orientation !== undefined) { + await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: { id, notify: true } }); + } } } } diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 54540dff66..e06684034f 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -215,7 +215,7 @@ export class MediaService extends BaseService { const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true'; - const orientation = useExtracted && asset.exifInfo?.orientation ? Number(asset.exifInfo.orientation) : undefined; + const orientation = asset.exifInfo?.orientation ? Number(asset.exifInfo.orientation) : undefined; const decodeOptions = { colorspace, processInvalidImages, size: image.preview.size, orientation }; const { data, info } = await this.mediaRepository.decodeImage(inputPath, decodeOptions); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 19c3656e01..f015447dc9 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -295,7 +295,7 @@ export class MetadataService extends BaseService { @OnJob({ name: JobName.SIDECAR_WRITE, queue: QueueName.SIDECAR }) async handleSidecarWrite(job: JobOf): Promise { - const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job; + const { id, description, dateTimeOriginal, latitude, longitude, rating, tags, orientation } = job; const [asset] = await this.assetRepository.getByIds([id], { tags: true }); if (!asset) { return JobStatus.FAILED; @@ -311,6 +311,7 @@ export class MetadataService extends BaseService { DateTimeOriginal: dateTimeOriginal, GPSLatitude: latitude, GPSLongitude: longitude, + 'Orientation#': orientation, Rating: rating, TagsList: tags ? tagsList : undefined, }, diff --git a/server/src/types.ts b/server/src/types.ts index 3a331127e6..7b88b27791 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -222,6 +222,7 @@ export interface ISidecarWriteJob extends IEntityJob { latitude?: number; longitude?: number; rating?: number; + orientation?: ExifOrientation; tags?: true; } diff --git a/web/src/lib/components/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts index f8cfd447f0..c6b4a72c9e 100644 --- a/web/src/lib/components/asset-viewer/actions/action.ts +++ b/web/src/lib/components/asset-viewer/actions/action.ts @@ -13,6 +13,7 @@ type ActionMap = { [AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto }; [AssetAction.UNSTACK]: { assets: AssetResponseDto[] }; [AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto }; + [AssetAction.ROTATE]: { asset: AssetResponseDto; counterclockwise: boolean }; }; export type Action = { diff --git a/web/src/lib/components/asset-viewer/actions/rotate-action.svelte b/web/src/lib/components/asset-viewer/actions/rotate-action.svelte new file mode 100644 index 0000000000..a0c5bcaf4e --- /dev/null +++ b/web/src/lib/components/asset-viewer/actions/rotate-action.svelte @@ -0,0 +1,96 @@ + + + + +{#if menuItem} + +{:else} + +{/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 442302198b..c7e975def8 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -7,14 +7,15 @@ import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte'; import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte'; import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte'; + import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte'; import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte'; + import RotateAction from '$lib/components/asset-viewer/actions/rotate-action.svelte'; import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte'; import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte'; import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte'; import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte'; import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte'; import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte'; - import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; @@ -22,6 +23,7 @@ import { user } from '$lib/stores/user.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; import { getAssetJobName, getSharedLink } from '$lib/utils'; + import { canCopyImageToClipboard } from '$lib/utils/asset-utils'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { AssetJobName, @@ -45,9 +47,8 @@ mdiPresentationPlay, mdiUpload, } from '@mdi/js'; - import { canCopyImageToClipboard } from '$lib/utils/asset-utils'; - import { t } from 'svelte-i18n'; import type { Snippet } from 'svelte'; + import { t } from 'svelte-i18n'; interface Props { asset: AssetResponseDto; @@ -87,6 +88,9 @@ const sharedLink = getSharedLink(); let isOwner = $derived($user && asset.ownerId === $user?.id); let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline); + let canRotate = $derived( + asset.type === AssetTypeEnum.Image && !asset.livePhotoVideoId && asset.exifInfo?.orientation !== undefined, + ); // $: showEditorButton = // isOwner && // asset.type === AssetTypeEnum.Image && @@ -180,6 +184,11 @@ {/if} + + {#if canRotate} + + + {/if} openFileUploadDialog({ multiple: false, assetId: asset.id })} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index ea5d6e9275..5d5158b9d1 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -43,10 +43,10 @@ import DetailPanel from './detail-panel.svelte'; import CropArea from './editor/crop-tool/crop-area.svelte'; import EditorPanel from './editor/editor-panel.svelte'; + import ImagePanoramaViewer from './image-panorama-viewer.svelte'; import PhotoViewer from './photo-viewer.svelte'; import SlideshowBar from './slideshow-bar.svelte'; import VideoViewer from './video-wrapper-viewer.svelte'; - import ImagePanoramaViewer from './image-panorama-viewer.svelte'; type HasAsset = boolean; @@ -190,7 +190,7 @@ } }; - const onAssetUpdate = (assetUpdate: AssetResponseDto) => { + const onAssetUpdate = ({ asset: assetUpdate }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => { if (assetUpdate.id === asset.id) { asset = assetUpdate; } @@ -198,8 +198,8 @@ onMount(async () => { unsubscribes.push( - websocketEvents.on('on_upload_success', onAssetUpdate), - websocketEvents.on('on_asset_update', onAssetUpdate), + websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })), + websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })), ); slideshowStateUnsubscribe = slideshowState.subscribe((value) => { @@ -377,6 +377,7 @@ case AssetAction.KEEP_THIS_DELETE_OTHERS: case AssetAction.UNSTACK: { closeViewer(); + break; } } @@ -483,7 +484,7 @@ {:else} navigateAsset('previous')} @@ -500,7 +501,7 @@ {#if shouldPlayMotionPhoto && asset.livePhotoVideoId} navigateAsset('previous')} @@ -529,7 +530,7 @@ {:else} navigateAsset('previous')} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte index 028074bc02..91fbd61e85 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte @@ -50,7 +50,7 @@ img = new Image(); await tick(); - img.src = getAssetOriginalUrl({ id: asset.id, checksum: asset.checksum }); + img.src = getAssetOriginalUrl({ id: asset.id, cacheKey: asset.thumbhash }); img.addEventListener('load', () => onImageLoad(true)); img.addEventListener('error', (error) => { diff --git a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts index e1372e37da..31b690ad0c 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts +++ b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts @@ -40,7 +40,7 @@ describe('PhotoViewer component', () => { expect(getAssetThumbnailUrlSpy).toBeCalledWith({ id: asset.id, size: AssetMediaSize.Preview, - checksum: asset.checksum, + cacheKey: asset.thumbhash, }); expect(getAssetOriginalUrlSpy).not.toBeCalled(); }); @@ -50,7 +50,7 @@ describe('PhotoViewer component', () => { render(PhotoViewer, { asset }); expect(getAssetThumbnailUrlSpy).not.toBeCalled(); - expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, checksum: asset.checksum }); + expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); }); it('loads original for shared link when download permission is true and showMetadata permission is true', () => { @@ -59,7 +59,7 @@ describe('PhotoViewer component', () => { render(PhotoViewer, { asset, sharedLink }); expect(getAssetThumbnailUrlSpy).not.toBeCalled(); - expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, checksum: asset.checksum }); + expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); }); it('not loads original image when shared link download permission is false', () => { @@ -70,7 +70,7 @@ describe('PhotoViewer component', () => { expect(getAssetThumbnailUrlSpy).toBeCalledWith({ id: asset.id, size: AssetMediaSize.Preview, - checksum: asset.checksum, + cacheKey: asset.thumbhash, }); expect(getAssetOriginalUrlSpy).not.toBeCalled(); @@ -84,7 +84,7 @@ describe('PhotoViewer component', () => { expect(getAssetThumbnailUrlSpy).toBeCalledWith({ id: asset.id, size: AssetMediaSize.Preview, - checksum: asset.checksum, + cacheKey: asset.thumbhash, }); expect(getAssetOriginalUrlSpy).not.toBeCalled(); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index bad8d3c404..50825dfa7a 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -70,19 +70,19 @@ for (const preloadAsset of preloadAssets || []) { if (preloadAsset.type === AssetTypeEnum.Image) { let img = new Image(); - img.src = getAssetUrl(preloadAsset.id, useOriginal, preloadAsset.checksum); + img.src = getAssetUrl(preloadAsset.id, useOriginal, preloadAsset.thumbhash); } } }; - const getAssetUrl = (id: string, useOriginal: boolean, checksum: string) => { + const getAssetUrl = (id: string, useOriginal: boolean, cacheKey: string | null) => { if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) { - return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, checksum }); + return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey }); } return useOriginal - ? getAssetOriginalUrl({ id, checksum }) - : getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, checksum }); + ? getAssetOriginalUrl({ id, cacheKey }) + : getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey }); }; copyImage = async () => { @@ -144,7 +144,7 @@ loader?.removeEventListener('error', onerror); }; }); - let isWebCompatible = $derived(isWebCompatibleImage(asset)); + let isWebCompatible = $derived(isWebCompatibleImage(asset) && !asset?.exifInfo?.orientation); let useOriginalByDefault = $derived(isWebCompatible && $alwaysLoadOriginalFile); // when true, will force loading of the original image @@ -158,7 +158,7 @@ preload(useOriginalImage, preloadAssets); }); - let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.checksum)); + let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.thumbhash)); void; onNextAsset?: () => void; onVideoEnded?: () => void; @@ -24,7 +24,7 @@ let { assetId, loopVideo, - checksum, + cacheKey, onPreviousAsset = () => {}, onNextAsset = () => {}, onVideoEnded = () => {}, @@ -39,7 +39,7 @@ onMount(() => { if (videoPlayer) { - assetFileUrl = getAssetPlaybackUrl({ id: assetId, checksum }); + assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey }); forceMuted = false; videoPlayer.load(); } @@ -106,7 +106,7 @@ onclose={() => onClose()} muted={forceMuted || $videoViewerMuted} bind:volume={$videoViewerVolume} - poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, checksum })} + poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })} src={assetFileUrl} > diff --git a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte index 7ee21e59a2..a5a94d85d4 100644 --- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -6,7 +6,7 @@ interface Props { assetId: string; projectionType: string | null | undefined; - checksum: string; + cacheKey: string | null; loopVideo: boolean; onClose?: () => void; onPreviousAsset?: () => void; @@ -18,7 +18,7 @@ let { assetId, projectionType, - checksum, + cacheKey, loopVideo, onPreviousAsset, onClose, @@ -33,7 +33,7 @@ {:else} ) => { return getBaseUrl() + url.pathname + url.search + url.hash; }; -export const getAssetOriginalUrl = (options: string | { id: string; checksum?: string }) => { +type AssetUrlOptions = { id: string; cacheKey?: string | null }; + +export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => { if (typeof options === 'string') { options = { id: options }; } - const { id, checksum } = options; - return createUrl(getAssetOriginalPath(id), { key: getKey(), c: checksum }); + const { id, cacheKey } = options; + return createUrl(getAssetOriginalPath(id), { key: getKey(), c: cacheKey }); }; -export const getAssetThumbnailUrl = (options: string | { id: string; size?: AssetMediaSize; checksum?: string }) => { +export const getAssetThumbnailUrl = (options: string | (AssetUrlOptions & { size?: AssetMediaSize })) => { if (typeof options === 'string') { options = { id: options }; } - const { id, size, checksum } = options; - return createUrl(getAssetThumbnailPath(id), { size, key: getKey(), c: checksum }); + const { id, size, cacheKey } = options; + return createUrl(getAssetThumbnailPath(id), { size, key: getKey(), c: cacheKey }); }; -export const getAssetPlaybackUrl = (options: string | { id: string; checksum?: string }) => { +export const getAssetPlaybackUrl = (options: string | AssetUrlOptions) => { if (typeof options === 'string') { options = { id: options }; } - const { id, checksum } = options; - return createUrl(getAssetPlaybackPath(id), { key: getKey(), c: checksum }); + const { id, cacheKey } = options; + return createUrl(getAssetPlaybackPath(id), { key: getKey(), c: cacheKey }); }; export const getProfileImageUrl = (user: UserResponseDto) =>