mirror of
https://github.com/immich-app/immich.git
synced 2026-01-27 23:36:05 -08:00
Compare commits
8 Commits
renovate/p
...
revert/pre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
819ea9fbc0 | ||
|
|
b4489bd4a5 | ||
|
|
e6e661f882 | ||
|
|
f467a5e2c8 | ||
|
|
818f7b3e9b | ||
|
|
44b4f35019 | ||
|
|
212c03ceff | ||
|
|
7cedb5ea04 |
6
.github/workflows/prepare-release.yml
vendored
6
.github/workflows/prepare-release.yml
vendored
@@ -109,12 +109,6 @@ jobs:
|
||||
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
|
||||
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||
|
||||
with:
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -298,9 +298,9 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Install dependencies
|
||||
run: pnpm --filter=immich-i18n install --frozen-lockfile
|
||||
run: pnpm --filter=immich-web install --frozen-lockfile
|
||||
- name: Format
|
||||
run: pnpm --filter=immich-i18n format:fix
|
||||
run: pnpm --filter=immich-web format:i18n
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||
id: verify-changed-files
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"jsonRecursiveSort": true,
|
||||
"jsonSortOrder": "{\"/.*/\": \"lexical\"}",
|
||||
"plugins": ["prettier-plugin-sort-json"]
|
||||
}
|
||||
@@ -572,6 +572,9 @@
|
||||
"asset_list_layout_sub_title": "Layout",
|
||||
"asset_list_settings_subtitle": "Photo grid layout settings",
|
||||
"asset_list_settings_title": "Photo Grid",
|
||||
"asset_not_found_on_device_android": "Asset not found on device",
|
||||
"asset_not_found_on_device_ios": "Asset not found on device. If you are using iCloud, the asset may be inaccessible due to bad file stored on iCloud",
|
||||
"asset_not_found_on_icloud": "Asset not found on iCloud. the asset may be inaccessible due to bad file stored on iCloud",
|
||||
"asset_offline": "Asset Offline",
|
||||
"asset_offline_description": "This external asset is no longer found on disk. Please contact your Immich administrator for help.",
|
||||
"asset_restored_successfully": "Asset restored successfully",
|
||||
@@ -2295,6 +2298,7 @@
|
||||
"upload_details": "Upload Details",
|
||||
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
|
||||
"upload_dialog_title": "Upload Asset",
|
||||
"upload_error_with_count": "Upload error for {count, plural, one {# asset} other {# assets}}",
|
||||
"upload_errors": "Upload completed with {count, plural, one {# error} other {# errors}}, refresh the page to see new upload assets.",
|
||||
"upload_finished": "Upload finished",
|
||||
"upload_progress": "Remaining {remaining, number} - Processed {processed, number}/{total, number}",
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "immich-i18n",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-sort-json": "^4.1.1"
|
||||
}
|
||||
}
|
||||
@@ -34,4 +34,4 @@ run = { task = ":i18n:format-fix" }
|
||||
|
||||
[tasks."i18n:format-fix"]
|
||||
dir = "i18n"
|
||||
run = "pnpm run format:fix"
|
||||
run = "pnpm dlx sort-json *.json"
|
||||
|
||||
@@ -62,6 +62,8 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
|
||||
final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress));
|
||||
|
||||
final errorCount = ref.watch(driftBackupProvider.select((state) => state.errorCount));
|
||||
|
||||
final isProcessing = uploadTasks.isNotEmpty || isSyncing || iCloudProgress.isNotEmpty;
|
||||
|
||||
return AnimatedBuilder(
|
||||
@@ -149,6 +151,14 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
),
|
||||
],
|
||||
),
|
||||
if (errorCount > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
"upload_error_with_count".t(context: context, args: {'count': '$errorCount'}),
|
||||
style: context.textTheme.labelMedium?.copyWith(color: context.colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -149,6 +149,8 @@ class DriftBackupState {
|
||||
);
|
||||
}
|
||||
|
||||
int get errorCount => uploadItems.values.where((item) => item.isFailed == true).length;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken, iCloudDownloadProgress: $iCloudDownloadProgress)';
|
||||
|
||||
@@ -260,6 +260,7 @@ class BackgroundUploadService {
|
||||
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
|
||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||
if (entity == null) {
|
||||
_logger.warning("Asset entity not found for ${asset.id} - ${asset.name}");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -282,6 +283,7 @@ class BackgroundUploadService {
|
||||
}
|
||||
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to get file for asset ${asset.id} - ${asset.name}");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
@@ -266,6 +267,10 @@ class ForegroundUploadService {
|
||||
try {
|
||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||
if (entity == null) {
|
||||
callbacks.onError?.call(
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -298,6 +303,11 @@ class ForegroundUploadService {
|
||||
// Get files locally
|
||||
file = await _storageRepository.getFileForAsset(asset.id);
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to get file ${asset.id} - ${asset.name}");
|
||||
callbacks.onError?.call(
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -306,12 +316,17 @@ class ForegroundUploadService {
|
||||
livePhotoFile = await _storageRepository.getMotionFileForAsset(asset);
|
||||
if (livePhotoFile == null) {
|
||||
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
|
||||
callbacks.onError?.call(
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to obtain file for asset ${asset.id} - ${asset.name}");
|
||||
_logger.warning("Failed to obtain file from iCloud for asset ${asset.id} - ${asset.name}");
|
||||
callbacks.onError?.call(asset.localId!, "asset_not_found_on_icloud".t());
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
|
||||
@@ -27,6 +28,8 @@ class SyncStatusAndActions extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final serverVersion = ref.watch(serverInfoProvider.select((value) => value.serverVersion));
|
||||
|
||||
Future<void> exportDatabase() async {
|
||||
try {
|
||||
// WAL Checkpoint to ensure all changes are written to the database
|
||||
@@ -135,6 +138,14 @@ class SyncStatusAndActions extends HookConsumerWidget {
|
||||
ref.read(backgroundSyncProvider).syncRemote();
|
||||
},
|
||||
),
|
||||
if (CurrentPlatform.isIOS && serverVersion.isAtLeast(major: 2, minor: 5))
|
||||
SettingListTile(
|
||||
title: "Sync Cloud Ids".t(context: context),
|
||||
leading: const Icon(Icons.cloud_circle_rounded),
|
||||
subtitle: "tap_to_run_job".t(context: context),
|
||||
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).cloudIdSyncStatus),
|
||||
onTap: ref.read(backgroundSyncProvider).syncCloudIds,
|
||||
),
|
||||
SettingListTile(
|
||||
title: "hash_asset".t(context: context),
|
||||
leading: const Icon(Icons.tag),
|
||||
|
||||
@@ -35,7 +35,7 @@ migration:
|
||||
dart run drift_dev make-migrations
|
||||
|
||||
translation:
|
||||
npm --prefix ../i18n run format:fix
|
||||
npm --prefix ../web run format:i18n
|
||||
dart run easy_localization:generate -S ../i18n
|
||||
dart run bin/generate_keys.dart
|
||||
dart format lib/generated/codegen_loader.g.dart
|
||||
|
||||
16641
pnpm-lock.yaml
generated
16641
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ packages:
|
||||
- docs
|
||||
- e2e
|
||||
- e2e-auth-server
|
||||
- i18n
|
||||
- open-api/typescript-sdk
|
||||
- server
|
||||
- plugins
|
||||
|
||||
@@ -308,7 +308,6 @@ export class MediaService extends BaseService {
|
||||
isEdited: useEdits,
|
||||
isProgressive: !!image.preview.progressive && image.preview.format !== ImageFormat.Webp,
|
||||
});
|
||||
previewFile.isProgressive = !!image.preview.progressive && image.preview.format !== ImageFormat.Webp;
|
||||
const thumbnailFile = this.getImageFile(asset, {
|
||||
fileType: AssetFileType.Thumbnail,
|
||||
format: image.thumbnail.format,
|
||||
@@ -349,10 +348,9 @@ export class MediaService extends BaseService {
|
||||
fullsizeFile = this.getImageFile(asset, {
|
||||
fileType: AssetFileType.FullSize,
|
||||
format: extracted.format,
|
||||
isEdited: useEdits,
|
||||
isEdited: false,
|
||||
isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp,
|
||||
});
|
||||
fullsizeFile.isProgressive = !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp;
|
||||
this.storageCore.ensureFolders(fullsizeFile.path);
|
||||
|
||||
// Write the buffer to disk with essential EXIF data
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"lint": "eslint . --max-warnings 0 --concurrency 4",
|
||||
"lint:fix": "pnpm run lint --fix",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"format:fix": "prettier --write . && pnpm run format:i18n",
|
||||
"format:i18n": "pnpm dlx sort-json ../i18n/*.json",
|
||||
"test": "vitest",
|
||||
"test:cov": "vitest --coverage",
|
||||
"test:watch": "vitest dev",
|
||||
@@ -61,6 +62,7 @@
|
||||
"svelte-persisted-store": "^0.12.0",
|
||||
"tabbable": "^6.2.0",
|
||||
"thumbhash": "^0.1.1",
|
||||
"transformation-matrix": "^3.1.0",
|
||||
"uplot": "^1.6.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
const axisOptions: Axis = {
|
||||
stroke: () => (isDark ? '#ccc' : 'black'),
|
||||
ticks: {
|
||||
show: true,
|
||||
show: false,
|
||||
stroke: () => (isDark ? '#444' : '#ddd'),
|
||||
},
|
||||
grid: {
|
||||
@@ -116,6 +116,8 @@
|
||||
axes: [
|
||||
{
|
||||
...axisOptions,
|
||||
size: 40,
|
||||
ticks: { show: true },
|
||||
values: (plot, values) => {
|
||||
return values.map((value) => {
|
||||
if (!value) {
|
||||
@@ -125,7 +127,10 @@
|
||||
});
|
||||
},
|
||||
},
|
||||
axisOptions,
|
||||
{
|
||||
...axisOptions,
|
||||
size: 60,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -7,10 +7,6 @@ describe('i18n', () => {
|
||||
const languageFiles = readdirSync('../i18n').sort();
|
||||
for (const filename of languageFiles) {
|
||||
test(`${filename} should have a loader`, async () => {
|
||||
if (!filename.endsWith('.json') || filename == 'package.json') {
|
||||
return;
|
||||
}
|
||||
|
||||
const code = filename.replaceAll('.json', '');
|
||||
const item = langs.find((lang) => lang.weblateCode === code || lang.code === code);
|
||||
expect(item, `${filename} has no loader`).toBeDefined();
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { editManager, type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getDimensions } from '$lib/utils/asset-utils';
|
||||
import { normalizeTransformEdits } from '$lib/utils/editor';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
AssetEditAction,
|
||||
AssetMediaSize,
|
||||
MirrorAxis,
|
||||
type AssetResponseDto,
|
||||
type CropParameters,
|
||||
type MirrorParameters,
|
||||
type RotateParameters,
|
||||
} from '@immich/sdk';
|
||||
import { AssetEditAction, AssetMediaSize, MirrorAxis, type AssetResponseDto, type CropParameters } from '@immich/sdk';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
export type CropAspectRatio =
|
||||
@@ -200,22 +193,14 @@ class TransformManager implements EditToolManager {
|
||||
|
||||
globalThis.addEventListener('mousemove', (e) => transformManager.handleMouseMove(e), { passive: true });
|
||||
|
||||
// set the rotation before loading the image
|
||||
const rotateEdit = edits.find((e) => e.action === 'rotate');
|
||||
if (rotateEdit) {
|
||||
this.imageRotation = (rotateEdit.parameters as RotateParameters).angle;
|
||||
}
|
||||
const transformEdits = edits.filter((e) => e.action === 'rotate' || e.action === 'mirror');
|
||||
|
||||
// set mirror state from edits
|
||||
const mirrorEdits = edits.filter((e) => e.action === 'mirror');
|
||||
for (const mirrorEdit of mirrorEdits) {
|
||||
const axis = (mirrorEdit.parameters as MirrorParameters).axis;
|
||||
if (axis === MirrorAxis.Horizontal) {
|
||||
this.mirrorHorizontal = true;
|
||||
} else if (axis === MirrorAxis.Vertical) {
|
||||
this.mirrorVertical = true;
|
||||
}
|
||||
}
|
||||
// Normalize rotation and mirror edits to single rotation and mirror state
|
||||
// This allows edits to be imported in any order and still produce correct state
|
||||
const normalizedTransformation = normalizeTransformEdits(transformEdits);
|
||||
this.imageRotation = normalizedTransformation.rotation;
|
||||
this.mirrorHorizontal = normalizedTransformation.mirrorHorizontal;
|
||||
this.mirrorVertical = normalizedTransformation.mirrorVertical;
|
||||
|
||||
await tick();
|
||||
|
||||
|
||||
21
web/src/lib/services/shared-link.service.spec.ts
Normal file
21
web/src/lib/services/shared-link.service.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { asUrl } from '$lib/services/shared-link.service';
|
||||
import type { ServerConfigDto } from '@immich/sdk';
|
||||
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
|
||||
|
||||
describe('SharedLinkService', () => {
|
||||
beforeAll(() => {
|
||||
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
|
||||
serverConfigManager: {
|
||||
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
|
||||
init: vi.fn(),
|
||||
loadServerConfig: vi.fn(),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
describe('asUrl', () => {
|
||||
it('should properly encode characters in slug', () => {
|
||||
expect(asUrl(sharedLinkFactory.build({ slug: 'foo/bar' }))).toBe('http://localhost:2283/s/foo%2Fbar');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -60,8 +60,10 @@ export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLin
|
||||
return { Edit, Delete, Copy, ViewQrCode };
|
||||
};
|
||||
|
||||
const asUrl = (sharedLink: SharedLinkResponseDto) => {
|
||||
const path = sharedLink.slug ? `s/${sharedLink.slug}` : `share/${sharedLink.key}`;
|
||||
export const asUrl = (sharedLink: SharedLinkResponseDto) => {
|
||||
const path = sharedLink.slug
|
||||
? `s/${encodeURIComponent(sharedLink.slug)}`
|
||||
: `share/${encodeURIComponent(sharedLink.key)}`;
|
||||
return new URL(path, serverConfigManager.value.externalDomain || globalThis.location.origin).href;
|
||||
};
|
||||
|
||||
|
||||
326
web/src/lib/utils/editor.spec.ts
Normal file
326
web/src/lib/utils/editor.spec.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import type { EditActions } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { buildAffineFromEdits, normalizeTransformEdits } from '$lib/utils/editor';
|
||||
import { AssetEditAction, MirrorAxis } from '@immich/sdk';
|
||||
|
||||
type NormalizedParameters = {
|
||||
rotation: number;
|
||||
mirrorHorizontal: boolean;
|
||||
mirrorVertical: boolean;
|
||||
};
|
||||
|
||||
function normalizedToEdits(params: NormalizedParameters): EditActions {
|
||||
const edits: EditActions = [];
|
||||
|
||||
if (params.mirrorHorizontal) {
|
||||
edits.push({
|
||||
action: AssetEditAction.Mirror,
|
||||
parameters: { axis: MirrorAxis.Horizontal },
|
||||
});
|
||||
}
|
||||
|
||||
if (params.mirrorVertical) {
|
||||
edits.push({
|
||||
action: AssetEditAction.Mirror,
|
||||
parameters: { axis: MirrorAxis.Vertical },
|
||||
});
|
||||
}
|
||||
|
||||
if (params.rotation !== 0) {
|
||||
edits.push({
|
||||
action: AssetEditAction.Rotate,
|
||||
parameters: { angle: params.rotation },
|
||||
});
|
||||
}
|
||||
|
||||
return edits;
|
||||
}
|
||||
|
||||
function compareEditAffines(editsA: EditActions, editsB: EditActions): boolean {
|
||||
const normA = buildAffineFromEdits(editsA);
|
||||
const normB = buildAffineFromEdits(editsB);
|
||||
|
||||
return (
|
||||
Math.abs(normA.a - normB.a) < 0.0001 &&
|
||||
Math.abs(normA.b - normB.b) < 0.0001 &&
|
||||
Math.abs(normA.c - normB.c) < 0.0001 &&
|
||||
Math.abs(normA.d - normB.d) < 0.0001
|
||||
);
|
||||
}
|
||||
|
||||
describe('edit normalization', () => {
|
||||
it('should handle no edits', () => {
|
||||
const edits: EditActions = [];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle a single 90° rotation', () => {
|
||||
const edits: EditActions = [{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle a single 180° rotation', () => {
|
||||
const edits: EditActions = [{ action: AssetEditAction.Rotate, parameters: { angle: 180 } }];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle a single 270° rotation', () => {
|
||||
const edits: EditActions = [{ action: AssetEditAction.Rotate, parameters: { angle: 270 } }];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle a single horizontal mirror', () => {
|
||||
const edits: EditActions = [{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle a single vertical mirror', () => {
|
||||
const edits: EditActions = [{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle 90° rotation + horizontal mirror', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle 90° rotation + vertical mirror', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle 90° rotation + both mirrors', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle 180° rotation + horizontal mirror', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 180 } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle 180° rotation + vertical mirror', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 180 } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle 180° rotation + both mirrors', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 180 } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle 270° rotation + horizontal mirror', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 270 } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle 270° rotation + vertical mirror', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 270 } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle 270° rotation + both mirrors', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 270 } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle horizontal mirror + 90° rotation', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle horizontal mirror + 180° rotation', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 180 } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle horizontal mirror + 270° rotation', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 270 } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle vertical mirror + 90° rotation', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle vertical mirror + 180° rotation', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 180 } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle vertical mirror + 270° rotation', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 270 } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle both mirrors + 90° rotation', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle both mirrors + 180° rotation', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 180 } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle both mirrors + 270° rotation', () => {
|
||||
const edits: EditActions = [
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 270 } },
|
||||
];
|
||||
|
||||
const result = normalizeTransformEdits(edits);
|
||||
const normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits)).toBe(true);
|
||||
});
|
||||
});
|
||||
43
web/src/lib/utils/editor.ts
Normal file
43
web/src/lib/utils/editor.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { EditActions } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import type { MirrorParameters, RotateParameters } from '@immich/sdk';
|
||||
import { compose, flipX, flipY, identity, rotate } from 'transformation-matrix';
|
||||
|
||||
const isCloseToZero = (x: number, epsilon: number = 1e-15) => Math.abs(x) < epsilon;
|
||||
|
||||
export const normalizeTransformEdits = (
|
||||
edits: EditActions,
|
||||
): {
|
||||
rotation: number;
|
||||
mirrorHorizontal: boolean;
|
||||
mirrorVertical: boolean;
|
||||
} => {
|
||||
const { a, b, c, d } = buildAffineFromEdits(edits);
|
||||
const rotation = ((isCloseToZero(a) ? Math.asin(c) : Math.acos(a)) * 180) / Math.PI;
|
||||
|
||||
return {
|
||||
rotation: rotation < 0 ? 360 + rotation : rotation,
|
||||
mirrorHorizontal: false,
|
||||
mirrorVertical: isCloseToZero(a) ? b === c : a === -d,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildAffineFromEdits = (edits: EditActions) =>
|
||||
compose(
|
||||
identity(),
|
||||
...edits.map((edit) => {
|
||||
switch (edit.action) {
|
||||
case 'rotate': {
|
||||
const parameters = edit.parameters as RotateParameters;
|
||||
const angleInRadians = (-parameters.angle * Math.PI) / 180;
|
||||
return rotate(angleInRadians);
|
||||
}
|
||||
case 'mirror': {
|
||||
const parameters = edit.parameters as MirrorParameters;
|
||||
return parameters.axis === 'horizontal' ? flipY() : flipX();
|
||||
}
|
||||
default: {
|
||||
return identity();
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user