feat(web): rotate image

This commit is contained in:
Jason Rasmussen 2025-02-13 17:02:44 -05:00
parent dbbefde98d
commit 9cd0871178
No known key found for this signature in database
GPG Key ID: 2EF24B77EAFA4A41
24 changed files with 441 additions and 53 deletions

View File

@ -602,6 +602,8 @@
"enable": "Enable", "enable": "Enable",
"enabled": "Enabled", "enabled": "Enabled",
"end_date": "End date", "end_date": "End date",
"rotate_left": "Rotate left",
"rotate_right": "Rotate right",
"error": "Error", "error": "Error",
"error_loading_image": "Error loading image", "error_loading_image": "Error loading image",
"error_title": "Error - Something went wrong", "error_title": "Error - Something went wrong",
@ -644,6 +646,7 @@
"quota_higher_than_disk_size": "You set a quota higher than the disk size", "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}}", "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_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_assets_to_shared_link": "Unable to add assets to shared link",
"unable_to_add_comment": "Unable to add comment", "unable_to_add_comment": "Unable to add comment",
"unable_to_add_exclusion_pattern": "Unable to add exclusion pattern", "unable_to_add_exclusion_pattern": "Unable to add exclusion pattern",

View File

@ -20,6 +20,7 @@ class AssetBulkUpdateDto {
this.isFavorite, this.isFavorite,
this.latitude, this.latitude,
this.longitude, this.longitude,
this.orientation,
this.rating, this.rating,
}); });
@ -67,6 +68,8 @@ class AssetBulkUpdateDto {
/// ///
num? longitude; num? longitude;
AssetBulkUpdateDtoOrientationEnum? orientation;
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
/// ///
@ -86,6 +89,7 @@ class AssetBulkUpdateDto {
other.isFavorite == isFavorite && other.isFavorite == isFavorite &&
other.latitude == latitude && other.latitude == latitude &&
other.longitude == longitude && other.longitude == longitude &&
other.orientation == orientation &&
other.rating == rating; other.rating == rating;
@override @override
@ -98,10 +102,11 @@ class AssetBulkUpdateDto {
(isFavorite == null ? 0 : isFavorite!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) +
(latitude == null ? 0 : latitude!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) +
(longitude == null ? 0 : longitude!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) +
(orientation == null ? 0 : orientation!.hashCode) +
(rating == null ? 0 : rating!.hashCode); (rating == null ? 0 : rating!.hashCode);
@override @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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -136,6 +141,11 @@ class AssetBulkUpdateDto {
} else { } else {
// json[r'longitude'] = null; // json[r'longitude'] = null;
} }
if (this.orientation != null) {
json[r'orientation'] = this.orientation;
} else {
// json[r'orientation'] = null;
}
if (this.rating != null) { if (this.rating != null) {
json[r'rating'] = this.rating; json[r'rating'] = this.rating;
} else { } else {
@ -162,6 +172,7 @@ class AssetBulkUpdateDto {
isFavorite: mapValueOfType<bool>(json, r'isFavorite'), isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
latitude: num.parse('${json[r'latitude']}'), latitude: num.parse('${json[r'latitude']}'),
longitude: num.parse('${json[r'longitude']}'), longitude: num.parse('${json[r'longitude']}'),
orientation: AssetBulkUpdateDtoOrientationEnum.fromJson(json[r'orientation']),
rating: num.parse('${json[r'rating']}'), 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 = <AssetBulkUpdateDtoOrientationEnum>[
number1,
number2,
number3,
number4,
number5,
number6,
number7,
number8,
];
static AssetBulkUpdateDtoOrientationEnum? fromJson(dynamic value) => AssetBulkUpdateDtoOrientationEnumTypeTransformer().decode(value);
static List<AssetBulkUpdateDtoOrientationEnum> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetBulkUpdateDtoOrientationEnum>[];
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;
}

View File

@ -20,6 +20,7 @@ class UpdateAssetDto {
this.latitude, this.latitude,
this.livePhotoVideoId, this.livePhotoVideoId,
this.longitude, this.longitude,
this.orientation,
this.rating, this.rating,
}); });
@ -73,6 +74,8 @@ class UpdateAssetDto {
/// ///
num? longitude; num? longitude;
UpdateAssetDtoOrientationEnum? orientation;
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
/// ///
@ -92,6 +95,7 @@ class UpdateAssetDto {
other.latitude == latitude && other.latitude == latitude &&
other.livePhotoVideoId == livePhotoVideoId && other.livePhotoVideoId == livePhotoVideoId &&
other.longitude == longitude && other.longitude == longitude &&
other.orientation == orientation &&
other.rating == rating; other.rating == rating;
@override @override
@ -104,10 +108,11 @@ class UpdateAssetDto {
(latitude == null ? 0 : latitude!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) +
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) + (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
(longitude == null ? 0 : longitude!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) +
(orientation == null ? 0 : orientation!.hashCode) +
(rating == null ? 0 : rating!.hashCode); (rating == null ? 0 : rating!.hashCode);
@override @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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -146,6 +151,11 @@ class UpdateAssetDto {
} else { } else {
// json[r'longitude'] = null; // json[r'longitude'] = null;
} }
if (this.orientation != null) {
json[r'orientation'] = this.orientation;
} else {
// json[r'orientation'] = null;
}
if (this.rating != null) { if (this.rating != null) {
json[r'rating'] = this.rating; json[r'rating'] = this.rating;
} else { } else {
@ -170,6 +180,7 @@ class UpdateAssetDto {
latitude: num.parse('${json[r'latitude']}'), latitude: num.parse('${json[r'latitude']}'),
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'), livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
longitude: num.parse('${json[r'longitude']}'), longitude: num.parse('${json[r'longitude']}'),
orientation: UpdateAssetDtoOrientationEnum.fromJson(json[r'orientation']),
rating: num.parse('${json[r'rating']}'), 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 = <UpdateAssetDtoOrientationEnum>[
number1,
number2,
number3,
number4,
number5,
number6,
number7,
number8,
];
static UpdateAssetDtoOrientationEnum? fromJson(dynamic value) => UpdateAssetDtoOrientationEnumTypeTransformer().decode(value);
static List<UpdateAssetDtoOrientationEnum> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UpdateAssetDtoOrientationEnum>[];
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;
}

View File

@ -7963,6 +7963,21 @@
"longitude": { "longitude": {
"type": "number" "type": "number"
}, },
"orientation": {
"enum": [
1,
2,
3,
4,
5,
6,
7,
8
],
"maximum": 8,
"minimum": 1,
"type": "integer"
},
"rating": { "rating": {
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
@ -12880,6 +12895,21 @@
"longitude": { "longitude": {
"type": "number" "type": "number"
}, },
"orientation": {
"enum": [
1,
2,
3,
4,
5,
6,
7,
8
],
"maximum": 8,
"minimum": 1,
"type": "integer"
},
"rating": { "rating": {
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,

View File

@ -391,6 +391,7 @@ export type AssetBulkUpdateDto = {
isFavorite?: boolean; isFavorite?: boolean;
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
orientation?: Orientation;
rating?: number; rating?: number;
}; };
export type AssetBulkUploadCheckItem = { export type AssetBulkUploadCheckItem = {
@ -439,6 +440,7 @@ export type UpdateAssetDto = {
latitude?: number; latitude?: number;
livePhotoVideoId?: string | null; livePhotoVideoId?: string | null;
longitude?: number; longitude?: number;
orientation?: Orientation;
rating?: number; rating?: number;
}; };
export type AssetMediaReplaceDto = { export type AssetMediaReplaceDto = {
@ -3481,6 +3483,16 @@ export enum AssetMediaStatus {
Replaced = "replaced", Replaced = "replaced",
Duplicate = "duplicate" Duplicate = "duplicate"
} }
export enum Orientation {
$1 = 1,
$2 = 2,
$3 = 3,
$4 = 4,
$5 = 5,
$6 = 6,
$7 = 7,
$8 = 8
}
export enum Action { export enum Action {
Accept = "accept", Accept = "accept",
Reject = "reject" Reject = "reject"

View File

@ -14,7 +14,7 @@ import {
ValidateIf, ValidateIf,
} from 'class-validator'; } from 'class-validator';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; 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 { AssetStats } from 'src/repositories/asset.repository';
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
@ -54,6 +54,12 @@ export class UpdateAssetBase {
@Max(5) @Max(5)
@Min(-1) @Min(-1)
rating?: number; rating?: number;
@Optional()
@Min(1)
@Max(8)
@ApiProperty({ type: 'integer' })
orientation?: ExifOrientation;
} }
export class AssetBulkUpdateDto extends UpdateAssetBase { export class AssetBulkUpdateDto extends UpdateAssetBase {

View File

@ -101,6 +101,7 @@ export class MetadataRepository {
} }
async writeTags(path: string, tags: Partial<Tags>): Promise<void> { async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
this.logger.verbose(`Writing tags ${JSON.stringify(tags)} to ${path}`);
try { try {
await this.exiftool.write(path, tags); await this.exiftool.write(path, tags);
} catch (error) { } catch (error) {

View File

@ -100,7 +100,7 @@ export class AssetService extends BaseService {
async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> { async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: [id] }); 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 }; const repos = { asset: this.assetRepository, event: this.eventRepository };
let previousMotion: AssetEntity | null = null; 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 }); const asset = await this.assetRepository.update({ id, ...rest });
@ -129,11 +129,12 @@ export class AssetService extends BaseService {
} }
async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> { async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto; const { ids, dateTimeOriginal, latitude, longitude, orientation, ...options } = dto;
await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids }); await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids });
// TODO rewrite this to support batching
for (const id of ids) { for (const id of ids) {
await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude, orientation });
} }
if ( if (
@ -284,11 +285,14 @@ export class AssetService extends BaseService {
} }
private async updateMetadata(dto: ISidecarWriteJob) { private async updateMetadata(dto: ISidecarWriteJob) {
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; const { id, description, dateTimeOriginal, latitude, longitude, rating, orientation } = dto;
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating, orientation }, _.isUndefined);
if (Object.keys(writes).length > 0) { if (Object.keys(writes).length > 0) {
await this.assetRepository.upsertExif({ assetId: id, ...writes }); await this.assetRepository.upsertExif({ assetId: id, ...writes });
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { 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 } });
}
} }
} }
} }

View File

@ -215,7 +215,7 @@ 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 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 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);

View File

@ -295,7 +295,7 @@ export class MetadataService extends BaseService {
@OnJob({ name: JobName.SIDECAR_WRITE, queue: QueueName.SIDECAR }) @OnJob({ name: JobName.SIDECAR_WRITE, queue: QueueName.SIDECAR })
async handleSidecarWrite(job: JobOf<JobName.SIDECAR_WRITE>): Promise<JobStatus> { async handleSidecarWrite(job: JobOf<JobName.SIDECAR_WRITE>): Promise<JobStatus> {
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 }); const [asset] = await this.assetRepository.getByIds([id], { tags: true });
if (!asset) { if (!asset) {
return JobStatus.FAILED; return JobStatus.FAILED;
@ -311,6 +311,7 @@ export class MetadataService extends BaseService {
DateTimeOriginal: dateTimeOriginal, DateTimeOriginal: dateTimeOriginal,
GPSLatitude: latitude, GPSLatitude: latitude,
GPSLongitude: longitude, GPSLongitude: longitude,
'Orientation#': orientation,
Rating: rating, Rating: rating,
TagsList: tags ? tagsList : undefined, TagsList: tags ? tagsList : undefined,
}, },

View File

@ -222,6 +222,7 @@ export interface ISidecarWriteJob extends IEntityJob {
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
rating?: number; rating?: number;
orientation?: ExifOrientation;
tags?: true; tags?: true;
} }

View File

@ -13,6 +13,7 @@ type ActionMap = {
[AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto }; [AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto };
[AssetAction.UNSTACK]: { assets: AssetResponseDto[] }; [AssetAction.UNSTACK]: { assets: AssetResponseDto[] };
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto }; [AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto };
[AssetAction.ROTATE]: { asset: AssetResponseDto; counterclockwise: boolean };
}; };
export type Action = { export type Action = {

View File

@ -0,0 +1,96 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AssetAction, ExifOrientation } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { mdiRotateLeft, mdiRotateRight } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
interface Props {
asset: AssetResponseDto;
onAction: OnAction;
counterclockwise?: boolean;
menuItem?: boolean;
}
let { asset, onAction, counterclockwise = false, menuItem }: Props = $props();
const icon = $derived(counterclockwise ? mdiRotateLeft : mdiRotateRight);
const text = $derived(counterclockwise ? $t('rotate_left') : $t('rotate_right'));
const getNextOrientation = (current: number) => {
switch (current) {
case -1:
case 0:
case ExifOrientation.Horizontal: {
return ExifOrientation.Rotate90CW;
}
case ExifOrientation.Rotate90CW: {
return ExifOrientation.Rotate180;
}
case ExifOrientation.Rotate180: {
return ExifOrientation.Rotate270CW;
}
case ExifOrientation.Rotate270CW: {
return ExifOrientation.Horizontal;
}
case ExifOrientation.MirrorHorizontal: {
return ExifOrientation.MirrorHorizontalRotate90CW;
}
case ExifOrientation.MirrorHorizontalRotate90CW: {
return ExifOrientation.MirrorVertical;
}
case ExifOrientation.MirrorVertical: {
return ExifOrientation.MirrorHorizontalRotate270CW;
}
case ExifOrientation.MirrorHorizontalRotate270CW: {
return ExifOrientation.MirrorVertical;
}
default: {
return current;
}
}
};
const handleRotate = async () => {
const current = Number(asset.exifInfo?.orientation);
if (!current && current !== 0) {
return;
}
const orientation = counterclockwise
? getNextOrientation(getNextOrientation(getNextOrientation(current)))
: getNextOrientation(current);
try {
const data = await updateAsset({ id: asset.id, updateAssetDto: { orientation } });
// TODO: remove if/when there is immediate UI feedback on image rotation (css animation)
notificationController.show({ message: text, type: NotificationType.Info });
onAction({ type: AssetAction.ROTATE, asset: data, counterclockwise });
} catch (error) {
handleError(error, $t('errors.unable_to_rotate_image'));
}
};
</script>
<svelte:window use:shortcut={{ shortcut: { key: 'r', shift: counterclockwise }, onShortcut: handleRotate }} />
{#if menuItem}
<MenuOption {icon} onClick={handleRotate} {text} />
{:else}
<CircleIconButton
color="opaque"
icon={counterclockwise ? mdiRotateLeft : mdiRotateRight}
title={text}
onclick={handleRotate}
/>
{/if}

View File

@ -7,14 +7,15 @@
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte'; import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
import DownloadAction from '$lib/components/asset-viewer/actions/download-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 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 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 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 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 SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
import ShareAction from '$lib/components/asset-viewer/actions/share-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 ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-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 CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.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'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
@ -22,6 +23,7 @@
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store'; import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName, getSharedLink } from '$lib/utils'; import { getAssetJobName, getSharedLink } from '$lib/utils';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { import {
AssetJobName, AssetJobName,
@ -45,9 +47,8 @@
mdiPresentationPlay, mdiPresentationPlay,
mdiUpload, mdiUpload,
} from '@mdi/js'; } from '@mdi/js';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { t } from 'svelte-i18n';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
@ -87,6 +88,9 @@
const sharedLink = getSharedLink(); const sharedLink = getSharedLink();
let isOwner = $derived($user && asset.ownerId === $user?.id); let isOwner = $derived($user && asset.ownerId === $user?.id);
let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline); let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
let canRotate = $derived(
asset.type === AssetTypeEnum.Image && !asset.livePhotoVideoId && asset.exifInfo?.orientation !== undefined,
);
// $: showEditorButton = // $: showEditorButton =
// isOwner && // isOwner &&
// asset.type === AssetTypeEnum.Image && // asset.type === AssetTypeEnum.Image &&
@ -180,6 +184,11 @@
<SetProfilePictureAction {asset} /> <SetProfilePictureAction {asset} />
{/if} {/if}
<ArchiveAction {asset} {onAction} /> <ArchiveAction {asset} {onAction} />
{#if canRotate}
<RotateAction {asset} {onAction} counterclockwise menuItem />
<RotateAction {asset} {onAction} menuItem />
{/if}
<MenuOption <MenuOption
icon={mdiUpload} icon={mdiUpload}
onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })} onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}

View File

@ -43,10 +43,10 @@
import DetailPanel from './detail-panel.svelte'; import DetailPanel from './detail-panel.svelte';
import CropArea from './editor/crop-tool/crop-area.svelte'; import CropArea from './editor/crop-tool/crop-area.svelte';
import EditorPanel from './editor/editor-panel.svelte'; import EditorPanel from './editor/editor-panel.svelte';
import ImagePanoramaViewer from './image-panorama-viewer.svelte';
import PhotoViewer from './photo-viewer.svelte'; import PhotoViewer from './photo-viewer.svelte';
import SlideshowBar from './slideshow-bar.svelte'; import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte'; import VideoViewer from './video-wrapper-viewer.svelte';
import ImagePanoramaViewer from './image-panorama-viewer.svelte';
type HasAsset = boolean; 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) { if (assetUpdate.id === asset.id) {
asset = assetUpdate; asset = assetUpdate;
} }
@ -198,8 +198,8 @@
onMount(async () => { onMount(async () => {
unsubscribes.push( unsubscribes.push(
websocketEvents.on('on_upload_success', onAssetUpdate), websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })),
websocketEvents.on('on_asset_update', onAssetUpdate), websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })),
); );
slideshowStateUnsubscribe = slideshowState.subscribe((value) => { slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
@ -377,6 +377,7 @@
case AssetAction.KEEP_THIS_DELETE_OTHERS: case AssetAction.KEEP_THIS_DELETE_OTHERS:
case AssetAction.UNSTACK: { case AssetAction.UNSTACK: {
closeViewer(); closeViewer();
break;
} }
} }
@ -483,7 +484,7 @@
{:else} {:else}
<VideoViewer <VideoViewer
assetId={previewStackedAsset.id} assetId={previewStackedAsset.id}
checksum={previewStackedAsset.checksum} cacheKey={previewStackedAsset.thumbhash}
projectionType={previewStackedAsset.exifInfo?.projectionType} projectionType={previewStackedAsset.exifInfo?.projectionType}
loopVideo={true} loopVideo={true}
onPreviousAsset={() => navigateAsset('previous')} onPreviousAsset={() => navigateAsset('previous')}
@ -500,7 +501,7 @@
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId} {#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
<VideoViewer <VideoViewer
assetId={asset.livePhotoVideoId} assetId={asset.livePhotoVideoId}
checksum={asset.checksum} cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType} projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow} loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')} onPreviousAsset={() => navigateAsset('previous')}
@ -529,7 +530,7 @@
{:else} {:else}
<VideoViewer <VideoViewer
assetId={asset.id} assetId={asset.id}
checksum={asset.checksum} cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType} projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow} loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')} onPreviousAsset={() => navigateAsset('previous')}

View File

@ -50,7 +50,7 @@
img = new Image(); img = new Image();
await tick(); 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('load', () => onImageLoad(true));
img.addEventListener('error', (error) => { img.addEventListener('error', (error) => {

View File

@ -40,7 +40,7 @@ describe('PhotoViewer component', () => {
expect(getAssetThumbnailUrlSpy).toBeCalledWith({ expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id, id: asset.id,
size: AssetMediaSize.Preview, size: AssetMediaSize.Preview,
checksum: asset.checksum, cacheKey: asset.thumbhash,
}); });
expect(getAssetOriginalUrlSpy).not.toBeCalled(); expect(getAssetOriginalUrlSpy).not.toBeCalled();
}); });
@ -50,7 +50,7 @@ describe('PhotoViewer component', () => {
render(PhotoViewer, { asset }); render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled(); 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', () => { 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 }); render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).not.toBeCalled(); 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', () => { it('not loads original image when shared link download permission is false', () => {
@ -70,7 +70,7 @@ describe('PhotoViewer component', () => {
expect(getAssetThumbnailUrlSpy).toBeCalledWith({ expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id, id: asset.id,
size: AssetMediaSize.Preview, size: AssetMediaSize.Preview,
checksum: asset.checksum, cacheKey: asset.thumbhash,
}); });
expect(getAssetOriginalUrlSpy).not.toBeCalled(); expect(getAssetOriginalUrlSpy).not.toBeCalled();
@ -84,7 +84,7 @@ describe('PhotoViewer component', () => {
expect(getAssetThumbnailUrlSpy).toBeCalledWith({ expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id, id: asset.id,
size: AssetMediaSize.Preview, size: AssetMediaSize.Preview,
checksum: asset.checksum, cacheKey: asset.thumbhash,
}); });
expect(getAssetOriginalUrlSpy).not.toBeCalled(); expect(getAssetOriginalUrlSpy).not.toBeCalled();

View File

@ -70,19 +70,19 @@
for (const preloadAsset of preloadAssets || []) { for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.type === AssetTypeEnum.Image) { if (preloadAsset.type === AssetTypeEnum.Image) {
let img = new 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)) { if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, checksum }); return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
} }
return useOriginal return useOriginal
? getAssetOriginalUrl({ id, checksum }) ? getAssetOriginalUrl({ id, cacheKey })
: getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, checksum }); : getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
}; };
copyImage = async () => { copyImage = async () => {
@ -144,7 +144,7 @@
loader?.removeEventListener('error', onerror); loader?.removeEventListener('error', onerror);
}; };
}); });
let isWebCompatible = $derived(isWebCompatibleImage(asset)); let isWebCompatible = $derived(isWebCompatibleImage(asset) && !asset?.exifInfo?.orientation);
let useOriginalByDefault = $derived(isWebCompatible && $alwaysLoadOriginalFile); let useOriginalByDefault = $derived(isWebCompatible && $alwaysLoadOriginalFile);
// when true, will force loading of the original image // when true, will force loading of the original image
@ -158,7 +158,7 @@
preload(useOriginalImage, preloadAssets); preload(useOriginalImage, preloadAssets);
}); });
let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.checksum)); let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.thumbhash));
</script> </script>
<svelte:window <svelte:window

View File

@ -13,7 +13,7 @@
interface Props { interface Props {
assetId: string; assetId: string;
loopVideo: boolean; loopVideo: boolean;
checksum: string; cacheKey: string | null;
onPreviousAsset?: () => void; onPreviousAsset?: () => void;
onNextAsset?: () => void; onNextAsset?: () => void;
onVideoEnded?: () => void; onVideoEnded?: () => void;
@ -24,7 +24,7 @@
let { let {
assetId, assetId,
loopVideo, loopVideo,
checksum, cacheKey,
onPreviousAsset = () => {}, onPreviousAsset = () => {},
onNextAsset = () => {}, onNextAsset = () => {},
onVideoEnded = () => {}, onVideoEnded = () => {},
@ -39,7 +39,7 @@
onMount(() => { onMount(() => {
if (videoPlayer) { if (videoPlayer) {
assetFileUrl = getAssetPlaybackUrl({ id: assetId, checksum }); assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey });
forceMuted = false; forceMuted = false;
videoPlayer.load(); videoPlayer.load();
} }
@ -106,7 +106,7 @@
onclose={() => onClose()} onclose={() => onClose()}
muted={forceMuted || $videoViewerMuted} muted={forceMuted || $videoViewerMuted}
bind:volume={$videoViewerVolume} bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, checksum })} poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl} src={assetFileUrl}
> >
</video> </video>

View File

@ -6,7 +6,7 @@
interface Props { interface Props {
assetId: string; assetId: string;
projectionType: string | null | undefined; projectionType: string | null | undefined;
checksum: string; cacheKey: string | null;
loopVideo: boolean; loopVideo: boolean;
onClose?: () => void; onClose?: () => void;
onPreviousAsset?: () => void; onPreviousAsset?: () => void;
@ -18,7 +18,7 @@
let { let {
assetId, assetId,
projectionType, projectionType,
checksum, cacheKey,
loopVideo, loopVideo,
onPreviousAsset, onPreviousAsset,
onClose, onClose,
@ -33,7 +33,7 @@
{:else} {:else}
<VideoNativeViewer <VideoNativeViewer
{loopVideo} {loopVideo}
{checksum} {cacheKey}
{assetId} {assetId}
{onPreviousAsset} {onPreviousAsset}
{onNextAsset} {onNextAsset}

View File

@ -327,7 +327,7 @@
{/if} {/if}
<ImageThumbnail <ImageThumbnail
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, checksum: asset.checksum })} url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)} altText={$getAltText(asset)}
widthStyle="{width}px" widthStyle="{width}px"
heightStyle="{height}px" heightStyle="{height}px"
@ -339,7 +339,7 @@
<div class="absolute top-0 h-full w-full"> <div class="absolute top-0 h-full w-full">
<VideoThumbnail <VideoThumbnail
{assetStore} {assetStore}
url={getAssetPlaybackUrl({ id: asset.id, checksum: asset.checksum })} url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover} enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected} curve={selected}
durationInSeconds={timeToSeconds(asset.duration)} durationInSeconds={timeToSeconds(asset.duration)}
@ -352,7 +352,7 @@
<div class="absolute top-0 h-full w-full"> <div class="absolute top-0 h-full w-full">
<VideoThumbnail <VideoThumbnail
{assetStore} {assetStore}
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, checksum: asset.checksum })} url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
pauseIcon={mdiMotionPauseOutline} pauseIcon={mdiMotionPauseOutline}
playIcon={mdiMotionPlayOutline} playIcon={mdiMotionPlayOutline}
showTime={false} showTime={false}

View File

@ -34,6 +34,8 @@
{ key: ['i'], action: $t('show_or_hide_info') }, { key: ['i'], action: $t('show_or_hide_info') },
{ key: ['s'], action: $t('stack_selected_photos') }, { key: ['s'], action: $t('stack_selected_photos') },
{ key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') }, { key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') },
{ key: ['r'], action: $t('rotate_right') },
{ key: ['⇧', 'r'], action: $t('rotate_left') },
{ key: ['⇧', 'd'], action: $t('download') }, { key: ['⇧', 'd'], action: $t('download') },
{ key: ['Space'], action: $t('play_or_pause_video') }, { key: ['Space'], action: $t('play_or_pause_video') },
{ key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') }, { key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') },

View File

@ -10,6 +10,19 @@ export enum AssetAction {
ADD_TO_ALBUM = 'add-to-album', ADD_TO_ALBUM = 'add-to-album',
UNSTACK = 'unstack', UNSTACK = 'unstack',
KEEP_THIS_DELETE_OTHERS = 'keep-this-delete-others', KEEP_THIS_DELETE_OTHERS = 'keep-this-delete-others',
ROTATE = 'rotate',
}
// copied from the server because numeric enums lose their names
export enum ExifOrientation {
Horizontal = 1,
MirrorHorizontal = 2,
Rotate180 = 3,
MirrorVertical = 4,
MirrorHorizontalRotate270CW = 5,
Rotate90CW = 6,
MirrorHorizontalRotate90CW = 7,
Rotate270CW = 8,
} }
export enum AppRoute { export enum AppRoute {

View File

@ -180,28 +180,30 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
return getBaseUrl() + url.pathname + url.search + url.hash; 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') { if (typeof options === 'string') {
options = { id: options }; options = { id: options };
} }
const { id, checksum } = options; const { id, cacheKey } = options;
return createUrl(getAssetOriginalPath(id), { key: getKey(), c: checksum }); 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') { if (typeof options === 'string') {
options = { id: options }; options = { id: options };
} }
const { id, size, checksum } = options; const { id, size, cacheKey } = options;
return createUrl(getAssetThumbnailPath(id), { size, key: getKey(), c: checksum }); 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') { if (typeof options === 'string') {
options = { id: options }; options = { id: options };
} }
const { id, checksum } = options; const { id, cacheKey } = options;
return createUrl(getAssetPlaybackPath(id), { key: getKey(), c: checksum }); return createUrl(getAssetPlaybackPath(id), { key: getKey(), c: cacheKey });
}; };
export const getProfileImageUrl = (user: UserResponseDto) => export const getProfileImageUrl = (user: UserResponseDto) =>