mirror of
https://github.com/immich-app/immich.git
synced 2026-02-04 08:49:01 +03:00
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>
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
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