mirror of
https://github.com/immich-app/immich.git
synced 2026-01-24 18:34:41 -08:00
Compare commits
2 Commits
chore/tran
...
fix/edit-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6205ec3c4 | ||
|
|
20ccbcec47 |
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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 normalizedTransfromation = normalizeTransformEdits(transformEdits);
|
||||
this.imageRotation = normalizedTransfromation.rotation;
|
||||
this.mirrorHorizontal = normalizedTransfromation.mirrorHorizontal;
|
||||
this.mirrorVertical = normalizedTransfromation.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);
|
||||
});
|
||||
});
|
||||
64
web/src/lib/utils/editor.ts
Normal file
64
web/src/lib/utils/editor.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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 = buildAffineFromEdits(edits);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user