diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49b479bbc8..9b9f7fcaf5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -842,6 +842,9 @@ importers: thumbhash: specifier: ^0.1.1 version: 0.1.1 + transformation-matrix: + specifier: ^3.1.0 + version: 3.1.0 uplot: specifier: ^1.6.32 version: 1.6.32 diff --git a/web/package.json b/web/package.json index 374a9de4e7..5055c8b2ce 100644 --- a/web/package.json +++ b/web/package.json @@ -61,6 +61,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": { diff --git a/web/src/lib/managers/edit/transform-manager.svelte.ts b/web/src/lib/managers/edit/transform-manager.svelte.ts index faa7871152..7673889185 100644 --- a/web/src/lib/managers/edit/transform-manager.svelte.ts +++ b/web/src/lib/managers/edit/transform-manager.svelte.ts @@ -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(); diff --git a/web/src/lib/utils/editor.spec.ts b/web/src/lib/utils/editor.spec.ts new file mode 100644 index 0000000000..fcaddad350 --- /dev/null +++ b/web/src/lib/utils/editor.spec.ts @@ -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); + }); +}); diff --git a/web/src/lib/utils/editor.ts b/web/src/lib/utils/editor.ts new file mode 100644 index 0000000000..7816fed022 --- /dev/null +++ b/web/src/lib/utils/editor.ts @@ -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(); + } + } + }), + );