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:
Brandon Wees
2026-01-27 10:55:10 -06:00
committed by GitHub
parent 818f7b3e9b
commit f467a5e2c8
5 changed files with 382 additions and 24 deletions

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