forked from Cutlery/immich
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ad94a9fc5 | |||
| 1f4df2e6e4 | |||
| 385bfcd957 | |||
| 7a52474e6e |
Generated
+1
@@ -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]
|
||||
|
||||
|
||||
Generated
+1
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -46,6 +46,11 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// num orientation
|
||||
test('to test the property `orientation`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+12
@@ -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;
|
||||
}
|
||||
/**
|
||||
*
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface ISidecarWriteJob extends IEntityJob {
|
||||
dateTimeOriginal?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
orientation?: number;
|
||||
}
|
||||
|
||||
export interface IDeferrableJob extends IEntityJob {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+862
-1880
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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",
|
||||
|
||||
Vendored
+1
@@ -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' },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user