mirror of
https://github.com/immich-app/immich.git
synced 2026-01-26 11:24:44 -08:00
Compare commits
3 Commits
refactor/a
...
fix/edit-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ede139c428 | ||
|
|
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);
|
||||
});
|
||||
});
|
||||
65
web/src/lib/utils/editor.ts
Normal file
65
web/src/lib/utils/editor.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
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;
|
||||
} {
|
||||
const { a, b, c, d } = buildAffineFromEdits(edits);
|
||||
|
||||
// 1. Extract rotation (full quadrant-safe)
|
||||
let rotation = (Math.atan2(b, a) * 180) / Math.PI;
|
||||
rotation = ((rotation % 360) + 360) % 360;
|
||||
|
||||
// 2. Build inverse rotation matrix
|
||||
const rad = (rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
// Inverse rotation * original matrix
|
||||
const ua = cos * a + sin * c;
|
||||
const ud = -sin * b + cos * d;
|
||||
|
||||
// 3. Detect mirrors in unrotated space
|
||||
const mirrorHorizontal = ua < 0;
|
||||
const mirrorVertical = ud < 0;
|
||||
|
||||
// 4. Fold double mirrors into rotation
|
||||
if (mirrorHorizontal && mirrorVertical) {
|
||||
return {
|
||||
rotation: (rotation + 180) % 360,
|
||||
mirrorHorizontal: false,
|
||||
mirrorVertical: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
rotation,
|
||||
mirrorHorizontal,
|
||||
mirrorVertical,
|
||||
};
|
||||
}
|
||||
|
||||
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