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 index dc14e7a6e5..04c4bae32a 100644 --- a/web/src/lib/utils/editor.ts +++ b/web/src/lib/utils/editor.ts @@ -9,24 +9,7 @@ export function normalizeTransformEdits(edits: EditActions): { } { // 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(); - } - } - }), - ); + const matrix = buildAffineFromEdits(edits); let rotation = 0; let mirrorH = false; @@ -57,3 +40,25 @@ export function normalizeTransformEdits(edits: EditActions): { mirrorVertical: mirrorV, }; } + +export function buildAffineFromEdits(edits: EditActions) { + return 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(); + } + } + }), + ); +}