Compare commits

...

8 Commits

Author SHA1 Message Date
bwees
819ea9fbc0 Revert "feat: use prettier for i18n translations (#24623)"
This reverts commit f52bd9f38a.
2026-01-27 11:58:51 -06:00
Alex
b4489bd4a5 chore: remove unused secrect reference (#25570) 2026-01-27 17:21:06 +00:00
Mert
e6e661f882 fix(server): set isEdited=false for extracted preview (#25568)
set isEdited=false for extracted preview
2026-01-27 10:58:47 -06:00
Brandon Wees
f467a5e2c8 fix(web): edit order handling (#25496)
* fix(web): edit order handling

* chore: tests

* simplify normalization function

* chore: refactor

---------

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-27 10:55:10 -06:00
Mees Frensel
818f7b3e9b fix(web): queue graph formatting for y-axis labels (#25567)
fix(web): queue graph formatting for y axis labels
2026-01-27 10:41:31 -06:00
Alex
44b4f35019 chore: expose upload errors to UI (#25566) 2026-01-27 16:33:44 +00:00
Daniel Dietzler
212c03ceff fix(web): properly encode shared link slug (#25564) 2026-01-27 16:29:51 +01:00
shenlong
7cedb5ea04 feat: add manual cloud id sync button (#25531)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-27 08:10:18 -06:00
23 changed files with 12846 additions and 4324 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -1,5 +0,0 @@
{
"jsonRecursiveSort": true,
"jsonSortOrder": "{\"/.*/\": \"lexical\"}",
"plugins": ["prettier-plugin-sort-json"]
}

View File

@@ -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}",

View File

@@ -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"
}
}

View File

@@ -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"

View File

@@ -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),
),
),
],
),
),

View File

@@ -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)';

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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),

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@ packages:
- docs
- e2e
- e2e-auth-server
- i18n
- open-api/typescript-sdk
- server
- plugins

View File

@@ -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

View File

@@ -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": {

View File

@@ -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,
},
],
};

View File

@@ -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();

View File

@@ -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();

View 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');
});
});
});

View File

@@ -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;
};

View 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);
});
});

View 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();
}
}
}),
);