diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6de0038411..8a1a25c261 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..25a29b41d2 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 normalizedTransfromation = normalizeTransformEdits(transformEdits); + this.imageRotation = normalizedTransfromation.rotation; + this.mirrorHorizontal = normalizedTransfromation.mirrorHorizontal; + this.mirrorVertical = normalizedTransfromation.mirrorVertical; await tick(); diff --git a/web/src/lib/utils/editor.ts b/web/src/lib/utils/editor.ts new file mode 100644 index 0000000000..dc14e7a6e5 --- /dev/null +++ b/web/src/lib/utils/editor.ts @@ -0,0 +1,59 @@ +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'; + +export function normalizeTransformEdits(edits: EditActions): { + rotation: number; + mirrorHorizontal: boolean; + mirrorVertical: boolean; +} { + // construct an affine matrix from the edits + // this is the same approach used in the backend to combine multiple transforms + const matrix = compose( + ...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(); + } + } + }), + ); + + let rotation = 0; + let mirrorH = false; + let mirrorV = false; + + let { a, b, c, d } = matrix; + // round to avoid floating point precision issues + a = Math.round(a); + b = Math.round(b); + c = Math.round(c); + d = Math.round(d); + + // [ +/-1, 0, 0, +/-1 ] indicates a 0° or 180° rotation with possible mirrors + // [ 0, +/-1, +/-1, 0 ] indicates a 90° or 270° rotation with possible mirrors + if (Math.abs(a) == 1 && Math.abs(b) == 0 && Math.abs(c) == 0 && Math.abs(d) == 1) { + rotation = a > 0 ? 0 : 180; + mirrorH = rotation === 0 ? a < 0 : a > 0; + mirrorV = rotation === 0 ? d < 0 : d > 0; + } else if (Math.abs(a) == 0 && Math.abs(b) == 1 && Math.abs(c) == 1 && Math.abs(d) == 0) { + rotation = c > 0 ? 90 : 270; + mirrorH = rotation === 90 ? c < 0 : c > 0; + mirrorV = rotation === 90 ? b > 0 : b < 0; + } + + return { + rotation, + mirrorHorizontal: mirrorH, + mirrorVertical: mirrorV, + }; +}