1
0
forked from Cutlery/immich

Compare commits

...

4 Commits

Author SHA1 Message Date
martabal 3ad94a9fc5 fix: generate thumbs 2024-01-26 16:58:45 +01:00
martabal 1f4df2e6e4 merge main 2024-01-26 15:22:36 +01:00
Jason Rasmussen 385bfcd957 chore: open api 2024-01-25 13:14:12 -05:00
Jason Rasmussen 7a52474e6e feat(web): rotate image 2024-01-25 13:14:01 -05:00
24 changed files with 1136 additions and 1911 deletions
+1
View File
@@ -14,6 +14,7 @@ Name | Type | Description | Notes
**isFavorite** | **bool** | | [optional]
**latitude** | **num** | | [optional]
**longitude** | **num** | | [optional]
**orientation** | **num** | | [optional]
**removeParent** | **bool** | | [optional]
**stackParentId** | **String** | | [optional]
+1
View File
@@ -14,6 +14,7 @@ Name | Type | Description | Notes
**isFavorite** | **bool** | | [optional]
**latitude** | **num** | | [optional]
**longitude** | **num** | | [optional]
**orientation** | **num** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+20 -1
View File
@@ -19,6 +19,7 @@ class AssetBulkUpdateDto {
this.isFavorite,
this.latitude,
this.longitude,
this.orientation,
this.removeParent,
this.stackParentId,
});
@@ -65,6 +66,14 @@ class AssetBulkUpdateDto {
///
num? longitude;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? orientation;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
@@ -89,6 +98,7 @@ class AssetBulkUpdateDto {
other.isFavorite == isFavorite &&
other.latitude == latitude &&
other.longitude == longitude &&
other.orientation == orientation &&
other.removeParent == removeParent &&
other.stackParentId == stackParentId;
@@ -101,11 +111,12 @@ class AssetBulkUpdateDto {
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(latitude == null ? 0 : latitude!.hashCode) +
(longitude == null ? 0 : longitude!.hashCode) +
(orientation == null ? 0 : orientation!.hashCode) +
(removeParent == null ? 0 : removeParent!.hashCode) +
(stackParentId == null ? 0 : stackParentId!.hashCode);
@override
String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, removeParent=$removeParent, stackParentId=$stackParentId]';
String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, orientation=$orientation, removeParent=$removeParent, stackParentId=$stackParentId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -135,6 +146,11 @@ class AssetBulkUpdateDto {
} else {
// json[r'longitude'] = null;
}
if (this.orientation != null) {
json[r'orientation'] = this.orientation;
} else {
// json[r'orientation'] = null;
}
if (this.removeParent != null) {
json[r'removeParent'] = this.removeParent;
} else {
@@ -168,6 +184,9 @@ class AssetBulkUpdateDto {
longitude: json[r'longitude'] == null
? null
: num.parse(json[r'longitude'].toString()),
orientation: json[r'orientation'] == null
? null
: num.parse(json[r'orientation'].toString()),
removeParent: mapValueOfType<bool>(json, r'removeParent'),
stackParentId: mapValueOfType<String>(json, r'stackParentId'),
);
+22 -3
View File
@@ -19,6 +19,7 @@ class UpdateAssetDto {
this.isFavorite,
this.latitude,
this.longitude,
this.orientation,
});
///
@@ -69,6 +70,14 @@ class UpdateAssetDto {
///
num? longitude;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? orientation;
@override
bool operator ==(Object other) => identical(this, other) || other is UpdateAssetDto &&
other.dateTimeOriginal == dateTimeOriginal &&
@@ -76,7 +85,8 @@ class UpdateAssetDto {
other.isArchived == isArchived &&
other.isFavorite == isFavorite &&
other.latitude == latitude &&
other.longitude == longitude;
other.longitude == longitude &&
other.orientation == orientation;
@override
int get hashCode =>
@@ -86,10 +96,11 @@ class UpdateAssetDto {
(isArchived == null ? 0 : isArchived!.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(latitude == null ? 0 : latitude!.hashCode) +
(longitude == null ? 0 : longitude!.hashCode);
(longitude == null ? 0 : longitude!.hashCode) +
(orientation == null ? 0 : orientation!.hashCode);
@override
String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude]';
String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, orientation=$orientation]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -123,6 +134,11 @@ class UpdateAssetDto {
} else {
// json[r'longitude'] = null;
}
if (this.orientation != null) {
json[r'orientation'] = this.orientation;
} else {
// json[r'orientation'] = null;
}
return json;
}
@@ -144,6 +160,9 @@ class UpdateAssetDto {
longitude: json[r'longitude'] == null
? null
: num.parse(json[r'longitude'].toString()),
orientation: json[r'orientation'] == null
? null
: num.parse(json[r'orientation'].toString()),
);
}
return null;
+5
View File
@@ -46,6 +46,11 @@ void main() {
// TODO
});
// num orientation
test('to test the property `orientation`', () async {
// TODO
});
// bool removeParent
test('to test the property `removeParent`', () async {
// TODO
+5
View File
@@ -46,6 +46,11 @@ void main() {
// TODO
});
// num orientation
test('to test the property `orientation`', () async {
// TODO
});
});
+6
View File
@@ -6752,6 +6752,9 @@
"longitude": {
"type": "number"
},
"orientation": {
"type": "number"
},
"removeParent": {
"type": "boolean"
},
@@ -9856,6 +9859,9 @@
},
"longitude": {
"type": "number"
},
"orientation": {
"type": "number"
}
},
"type": "object"
+12
View File
@@ -483,6 +483,12 @@ export interface AssetBulkUpdateDto {
* @memberof AssetBulkUpdateDto
*/
'longitude'?: number;
/**
*
* @type {number}
* @memberof AssetBulkUpdateDto
*/
'orientation'?: number;
/**
*
* @type {boolean}
@@ -4438,6 +4444,12 @@ export interface UpdateAssetDto {
* @memberof UpdateAssetDto
*/
'longitude'?: number;
/**
*
* @type {number}
* @memberof UpdateAssetDto
*/
'orientation'?: number;
}
/**
*
+9 -6
View File
@@ -329,15 +329,18 @@ export class AssetService {
async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id);
const { description, dateTimeOriginal, latitude, longitude, ...rest } = dto;
await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude });
const { description, dateTimeOriginal, latitude, longitude, orientation, ...rest } = dto;
await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, orientation });
const asset = await this.assetRepository.save({ id, ...rest });
if (orientation) {
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id } });
}
return mapAsset(asset);
}
async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
const { ids, removeParent, dateTimeOriginal, latitude, longitude, ...options } = dto;
const { ids, removeParent, dateTimeOriginal, latitude, longitude, orientation, ...options } = dto;
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, ids);
if (removeParent) {
@@ -358,7 +361,7 @@ export class AssetService {
}
for (const id of ids) {
await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude });
await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude, orientation });
}
for (const id of ids) {
@@ -532,8 +535,8 @@ export class AssetService {
}
private async updateMetadata(dto: ISidecarWriteJob) {
const { id, description, dateTimeOriginal, latitude, longitude } = dto;
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude }, _.isUndefined);
const { id, description, dateTimeOriginal, latitude, longitude, orientation } = dto;
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, 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 } });
+15
View File
@@ -11,6 +11,7 @@ import {
IsNotEmpty,
IsPositive,
IsString,
Max,
Min,
ValidateIf,
} from 'class-validator';
@@ -208,6 +209,13 @@ export class AssetBulkUpdateDto extends BulkIdsDto {
@IsLongitude()
@IsNotEmpty()
longitude?: number;
@Optional()
@IsInt()
@Min(1)
@Max(8)
@Type(() => Number)
orientation?: number;
}
export class UpdateAssetDto {
@@ -236,6 +244,13 @@ export class UpdateAssetDto {
@IsLongitude()
@IsNotEmpty()
longitude?: number;
@Optional()
@IsInt()
@Min(1)
@Max(8)
@Type(() => Number)
orientation?: number;
}
export class RandomAssetsDto {
+1
View File
@@ -34,6 +34,7 @@ export interface ISidecarWriteJob extends IEntityJob {
dateTimeOriginal?: string;
latitude?: number;
longitude?: number;
orientation?: number;
}
export interface IDeferrableJob extends IEntityJob {
+12 -4
View File
@@ -167,12 +167,16 @@ export class MediaService {
return false;
}
const resizePath = await this.generateThumbnail(asset, 'jpeg');
const resizePath = await this.generateThumbnail(
asset,
'jpeg',
asset.exifInfo?.orientation ? parseInt(asset.exifInfo?.orientation) : undefined,
);
await this.assetRepository.save({ id: asset.id, resizePath });
return true;
}
private async generateThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
private async generateThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp', orientation?: number) {
const { thumbnail, ffmpeg } = await this.configCore.getConfig();
const size = format === 'jpeg' ? thumbnail.jpegSize : thumbnail.webpSize;
const path =
@@ -182,7 +186,7 @@ export class MediaService {
switch (asset.type) {
case AssetType.IMAGE:
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : thumbnail.colorspace;
const thumbnailOptions = { format, size, colorspace, quality: thumbnail.quality };
const thumbnailOptions = { format, size, colorspace, quality: thumbnail.quality, orientation };
await this.mediaRepository.resize(asset.originalPath, path, thumbnailOptions);
break;
@@ -214,7 +218,11 @@ export class MediaService {
return false;
}
const webpPath = await this.generateThumbnail(asset, 'webp');
const webpPath = await this.generateThumbnail(
asset,
'webp',
asset.exifInfo?.orientation ? parseInt(asset.exifInfo?.orientation) : undefined,
);
await this.assetRepository.save({ id: asset.id, webpPath });
return true;
}
+19 -3
View File
@@ -1,7 +1,7 @@
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common';
import { ExifDateTime, Tags } from 'exiftool-vendored';
import { ExifDateTime, Tags, WriteTags } from 'exiftool-vendored';
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
import { constants } from 'fs/promises';
import _ from 'lodash';
@@ -66,6 +66,21 @@ export enum Orientation {
Rotate270CW = '8',
}
export const orientationExifToDegrees = (orientation: Orientation): number | undefined => {
switch (orientation) {
case Orientation.Horizontal:
return 0;
case Orientation.Rotate180:
return 180;
case Orientation.Rotate90CW:
return 90;
case Orientation.Rotate270CW:
return 270;
default:
return undefined;
}
};
type ExifEntityWithoutGeocodeAndTypeOrm = Omit<
ExifEntity,
'city' | 'state' | 'country' | 'description' | 'exifTextSearchableColumn'
@@ -303,19 +318,20 @@ export class MetadataService {
}
async handleSidecarWrite(job: ISidecarWriteJob) {
const { id, description, dateTimeOriginal, latitude, longitude } = job;
const { id, description, dateTimeOriginal, latitude, longitude, orientation } = job;
const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) {
return false;
}
const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`;
const exif = _.omitBy<Tags>(
const exif = _.omitBy<WriteTags>(
{
ImageDescription: description,
CreationDate: dateTimeOriginal,
GPSLatitude: latitude,
GPSLongitude: longitude,
'Orientation#': orientation,
},
_.isUndefined,
);
@@ -8,6 +8,7 @@ export interface ResizeOptions {
format: 'webp' | 'jpeg';
colorspace: string;
quality: number;
orientation?: number;
}
export interface VideoStreamInfo {
@@ -1,4 +1,4 @@
import { BinaryField, Tags } from 'exiftool-vendored';
import { BinaryField, Tags, WriteTags } from 'exiftool-vendored';
export const IMetadataRepository = 'IMetadataRepository';
@@ -30,6 +30,7 @@ export interface ImmichTags extends Omit<Tags, 'FocalLength' | 'Duration'> {
EmbeddedVideoType?: string;
EmbeddedVideoFile?: BinaryField;
MotionPhotoVideo?: BinaryField;
Orientation?: number;
}
export interface IMetadataRepository {
@@ -37,6 +38,6 @@ export interface IMetadataRepository {
teardown(): Promise<void>;
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult | null>;
readTags(path: string): Promise<ImmichTags | null>;
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
writeTags(path: string, tags: Partial<WriteTags>): Promise<void>;
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
}
@@ -1,4 +1,12 @@
import { CropOptions, IMediaRepository, ResizeOptions, TranscodeOptions, VideoInfo } from '@app/domain';
import {
CropOptions,
IMediaRepository,
Orientation,
ResizeOptions,
TranscodeOptions,
VideoInfo,
orientationExifToDegrees,
} from '@app/domain';
import { Colorspace } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
@@ -27,10 +35,18 @@ export class MediaRepository implements IMediaRepository {
}
async resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void> {
const orientation = options.orientation;
const rotation =
options.format === 'webp'
? orientation
? orientationExifToDegrees(orientation.toString() as Orientation)
: undefined
: undefined;
await sharp(input, { failOn: 'none' })
.pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16')
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
.rotate()
.rotate(rotation)
.withMetadata({ orientation })
.withIccProfile(options.colorspace)
.toFormat(options.format, {
quality: options.quality,
@@ -14,7 +14,7 @@ import { GeodataAdmin1Entity, GeodataAdmin2Entity, GeodataPlacesEntity, SystemMe
import { ImmichLogger } from '@app/infra/logger';
import { Inject } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DefaultReadTaskOptions, exiftool, Tags } from 'exiftool-vendored';
import { DefaultReadTaskOptions, exiftool, WriteTags } from 'exiftool-vendored';
import { createReadStream, existsSync } from 'fs';
import { readFile } from 'fs/promises';
import * as geotz from 'geo-tz';
@@ -205,7 +205,7 @@ export class MetadataRepository implements IMetadataRepository {
return exiftool.extractBinaryTagToBuffer(tagName, path);
}
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
async writeTags(path: string, tags: Partial<WriteTags>): Promise<void> {
try {
await exiftool.write(path, tags, ['-overwrite_original']);
} catch (error) {
+862 -1880
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -58,7 +58,7 @@
"@egjs/svelte-view360": "^4.0.0-beta.7",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@mdi/js": "^7.3.67",
"@zoom-image/svelte": "^0.2.0",
"@zoom-image/svelte": "^0.2.2",
"axios": "^0.27.2",
"buffer": "^6.0.3",
"copy-image-clipboard": "^2.1.2",
+1
View File
@@ -25,5 +25,6 @@ declare namespace svelteHTML {
interface HTMLAttributes<T> {
'on:copyImage'?: () => void;
'on:zoomImage'?: () => void;
'on:rotateImage'?: () => void;
}
}
@@ -36,7 +36,14 @@
$: isOwner = asset.ownerId === $user?.id;
type MenuItemEvent = 'addToAlbum' | 'addToSharedAlbum' | 'asProfileImage' | 'runJob' | 'playSlideShow' | 'unstack';
type MenuItemEvent =
| 'addToAlbum'
| 'addToSharedAlbum'
| 'asProfileImage'
| 'runJob'
| 'playSlideShow'
| 'unstack'
| 'rotate';
const dispatch = createEventDispatcher<{
back: void;
@@ -53,6 +60,7 @@
runJob: AssetJobName;
playSlideShow: void;
unstack: void;
rotate: void;
}>();
let contextMenuPosition = { x: 0, y: 0 };
@@ -175,7 +183,7 @@
text={asset.isArchived ? 'Unarchive' : 'Archive'}
/>
<MenuOption on:click={() => onMenuClick('asProfileImage')} text="As profile picture" />
<MenuOption on:click={() => onMenuClick('rotate')} text="Rotate right" />
{#if hasStackChildren}
<MenuOption on:click={() => onMenuClick('unstack')} text="Un-Stack" />
{/if}
@@ -53,7 +53,6 @@
export let album: AlbumResponseDto | null = null;
let reactions: ActivityResponseDto[] = [];
const { setAssetId } = assetViewingStore;
const {
restartProgress: restartSlideshowProgress,
@@ -250,6 +249,11 @@
isShowActivity = !isShowActivity;
};
const handleRotate = () => {
const rotateImage = new CustomEvent('rotateImage');
window.dispatchEvent(rotateImage);
};
const handleKeyboardPress = (event: KeyboardEvent) => {
if (shouldIgnoreShortcut(event)) {
return;
@@ -294,6 +298,11 @@
isShowActivity = false;
$isShowDetail = !$isShowDetail;
return;
case 'R':
case 'r':
if (shiftKey) {
handleRotate();
}
}
};
@@ -594,6 +603,7 @@
on:runJob={({ detail: job }) => handleRunJob(job)}
on:playSlideShow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
on:unstack={handleUnstack}
on:rotate={handleRotate}
/>
</div>
{/if}
@@ -11,11 +11,89 @@
import { photoViewer } from '$lib/stores/assets.store';
import { getBoundingBox } from '$lib/utils/people-utils';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { handleError } from '$lib/utils/handle-error';
import { user } from '$lib/stores/user.store';
export let asset: AssetResponseDto;
export let element: HTMLDivElement | undefined = undefined;
export let haveFadeTransition = true;
export let element: HTMLDivElement | undefined = undefined;
const orientationToRotation = (value: string): number => {
if (value === '1' || value === '6' || value === '8') {
if (imgWidth > imgHeight) {
return 0;
} else {
return 90;
}
}
switch (value) {
case '1':
return 0;
case '2':
return 0;
case '3':
return 180;
case '4':
return 0;
case '5':
return 270;
case '6':
return 90;
case '7':
return 90;
case '8':
return 270;
default:
return 0;
}
};
const getRotationModulo = (rotation: number): number => {
return ((rotation % 360) + 360) % 360;
};
$: {
getRotationModulo($zoomImageWheelState.currentRotation) === 0 ||
getRotationModulo($zoomImageWheelState.currentRotation) === 180
? ([imgHeight, imgWidth] = [clientHeight, clientWidth])
: ([imgWidth, imgHeight] = [clientHeight, clientWidth]);
}
const rotationToOrientation = (rotation: number): number => {
switch (getRotationModulo(rotation)) {
case 0:
return 1;
case 90:
return 8;
case 180:
return 3;
case 270:
return 6;
default:
return 1;
}
};
const doRotate = async () => {
setZoomImageWheelState({ currentRotation: $zoomImageWheelState.currentRotation + 90, currentZoom: 1 });
if (($user && $user.id !== asset.ownerId) || $user === null || asset.isReadOnly) {
return;
}
try {
await api.assetApi.updateAsset({
id: asset.id,
updateAssetDto: { orientation: rotationToOrientation($zoomImageWheelState.currentRotation) },
});
} catch (error) {
handleError(error, 'Unable to change orientation');
}
};
let clientWidth: number;
let clientHeight: number;
let imgWidth: number;
let imgHeight: number;
let imgElement: HTMLDivElement;
let assetData: string;
let abortController: AbortController;
@@ -120,19 +198,34 @@
loadAssetData({ loadOriginal: true });
}
});
$: if (imgElement) {
createZoomImageWheel(imgElement, {
maxZoom: 10,
wheelZoomRatio: 0.2,
});
}
</script>
<svelte:window on:keydown={handleKeypress} on:copyImage={doCopy} on:zoomImage={doZoomImage} />
<svelte:window on:keydown={handleKeypress} on:rotateImage={doRotate} on:copyImage={doCopy} on:zoomImage={doZoomImage} />
<div
bind:this={element}
bind:clientHeight
bind:clientWidth
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
class="flex h-full select-none place-content-center place-items-center"
class="flex h-full w-full select-none place-content-center place-items-center"
>
{#await loadAssetData({ loadOriginal: false })}
<LoadingSpinner />
{:then}
<div bind:this={imgElement} class="h-full w-full">
<div
bind:this={imgElement}
class="duration-500"
style={asset.exifInfo?.orientation
? `transform: rotate(${orientationToRotation(asset.exifInfo?.orientation)}deg);`
: ''}
>
<img
bind:this={$photoViewer}
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
@@ -140,6 +233,7 @@
alt={asset.id}
class="h-full w-full object-contain"
draggable="false"
style={`width:${imgWidth}px;height:${imgHeight}px;transform-origin: 0px 0px 0px;`}
/>
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox}
<div
@@ -27,6 +27,7 @@
{ key: ['i'], action: 'Show or hide info' },
{ key: ['⇧', 'a'], action: 'Archive or unarchive photo' },
{ key: ['⇧', 'd'], action: 'Download' },
{ key: ['⇧', 'r'], action: 'Rotate' },
{ key: ['Space'], action: 'Play or pause video' },
{ key: ['Del'], action: 'Trash/Delete Asset', info: 'press ⇧ to permanently delete asset' },
],