mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: rewrite animation system to use bindings and scalar channels
This commit is contained in:
@@ -1,235 +1,237 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
|
||||
import { getMaskSnapGeometry } from "@/lib/masks/geometry";
|
||||
import { snapMaskInteraction } from "@/lib/masks/snap";
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
|
||||
|
||||
const bounds: ElementBounds = {
|
||||
cx: 200,
|
||||
cy: 150,
|
||||
width: 200,
|
||||
height: 100,
|
||||
rotation: 0,
|
||||
};
|
||||
|
||||
const canvasSize = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
};
|
||||
|
||||
const snapThreshold = {
|
||||
x: 8,
|
||||
y: 8,
|
||||
};
|
||||
|
||||
function buildSplitParams(
|
||||
overrides: Partial<SplitMaskParams> = {},
|
||||
): SplitMaskParams {
|
||||
return {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
rotation: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRectangleParams(
|
||||
overrides: Partial<RectangleMaskParams> = {},
|
||||
): RectangleMaskParams {
|
||||
return {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
width: 0.4,
|
||||
height: 0.2,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function sortSegment(
|
||||
segment: [{ x: number; y: number }, { x: number; y: number }],
|
||||
): [{ x: number; y: number }, { x: number; y: number }] {
|
||||
return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [
|
||||
{ x: number; y: number },
|
||||
{ x: number; y: number },
|
||||
];
|
||||
}
|
||||
|
||||
describe("mask geometry", () => {
|
||||
test("resolves split mask center from centerX and centerY", () => {
|
||||
expect(
|
||||
getMaskSnapGeometry({
|
||||
params: buildSplitParams({
|
||||
centerX: 0.25,
|
||||
centerY: -0.5,
|
||||
rotation: 45,
|
||||
}),
|
||||
bounds,
|
||||
}),
|
||||
).toEqual({
|
||||
position: { x: 50, y: -50 },
|
||||
size: { width: 0, height: 0 },
|
||||
rotation: 45,
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves box mask center and size from centerX and centerY", () => {
|
||||
expect(
|
||||
getMaskSnapGeometry({
|
||||
params: buildRectangleParams({
|
||||
centerX: -0.25,
|
||||
centerY: 0.5,
|
||||
width: 0.5,
|
||||
height: 0.6,
|
||||
rotation: 30,
|
||||
}),
|
||||
bounds,
|
||||
}),
|
||||
).toEqual({
|
||||
position: { x: -50, y: 50 },
|
||||
size: { width: 100, height: 60 },
|
||||
rotation: 30,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a vertical split stroke segment for rotation 0", () => {
|
||||
const segment = getSplitMaskStrokeSegment({
|
||||
resolvedParams: buildSplitParams(),
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
|
||||
expect(segment).not.toBeNull();
|
||||
if (!segment) {
|
||||
throw new Error("Expected split stroke segment for rotation 0");
|
||||
}
|
||||
expect(sortSegment(segment)).toEqual([
|
||||
{ x: bounds.width / 2, y: 0 },
|
||||
{ x: bounds.width / 2, y: bounds.height },
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns a horizontal split stroke segment for rotation 90", () => {
|
||||
const segment = getSplitMaskStrokeSegment({
|
||||
resolvedParams: buildSplitParams({ rotation: 90 }),
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
|
||||
expect(segment).not.toBeNull();
|
||||
if (!segment) {
|
||||
throw new Error("Expected split stroke segment for rotation 90");
|
||||
}
|
||||
expect(sortSegment(segment)).toEqual([
|
||||
{ x: 0, y: bounds.height / 2 },
|
||||
{ x: bounds.width, y: bounds.height / 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mask snapping", () => {
|
||||
test("snaps split mask movement using the shared position pipeline", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "position",
|
||||
startParams: buildSplitParams({
|
||||
centerX: 0.03,
|
||||
centerY: -0.04,
|
||||
}),
|
||||
proposedParams: buildSplitParams({
|
||||
centerX: 0.03,
|
||||
centerY: -0.04,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.centerX).toBe(0);
|
||||
expect(result.params.centerY).toBe(0);
|
||||
expect(result.activeLines).toEqual([
|
||||
{ type: "vertical", position: 0 },
|
||||
{ type: "horizontal", position: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("snaps box mask movement against element center and edges", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "position",
|
||||
startParams: buildRectangleParams(),
|
||||
proposedParams: buildRectangleParams({
|
||||
centerX: 0.29,
|
||||
centerY: 0.03,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.centerX).toBeCloseTo(0.3);
|
||||
expect(result.params.centerY).toBe(0);
|
||||
expect(result.activeLines).toEqual([
|
||||
{ type: "vertical", position: 100 },
|
||||
{ type: "horizontal", position: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("snaps mask rotation through the shared rotation path", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "rotation",
|
||||
startParams: buildRectangleParams(),
|
||||
proposedParams: buildRectangleParams({
|
||||
rotation: 88,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.rotation).toBe(90);
|
||||
expect(result.activeLines).toEqual([]);
|
||||
});
|
||||
|
||||
test("snaps edge resize for box masks", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "right",
|
||||
startParams: buildRectangleParams(),
|
||||
proposedParams: buildRectangleParams({
|
||||
width: 0.98,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.width).toBe(1);
|
||||
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
||||
});
|
||||
|
||||
test("snaps corner resize for box masks", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "bottom-right",
|
||||
startParams: buildRectangleParams(),
|
||||
proposedParams: buildRectangleParams({
|
||||
width: 0.99,
|
||||
height: 0.495,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.width).toBe(1);
|
||||
expect(result.params.height).toBe(0.5);
|
||||
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
||||
});
|
||||
});
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
|
||||
import { getMaskSnapGeometry } from "@/lib/masks/geometry";
|
||||
import { snapMaskInteraction } from "@/lib/masks/snap";
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
|
||||
|
||||
const bounds: ElementBounds = {
|
||||
cx: 200,
|
||||
cy: 150,
|
||||
width: 200,
|
||||
height: 100,
|
||||
rotation: 0,
|
||||
};
|
||||
|
||||
const canvasSize = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
};
|
||||
|
||||
const snapThreshold = {
|
||||
x: 8,
|
||||
y: 8,
|
||||
};
|
||||
|
||||
function buildSplitParams(
|
||||
overrides: Partial<SplitMaskParams> = {},
|
||||
): SplitMaskParams {
|
||||
return {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
strokeAlign: "center",
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
rotation: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRectangleParams(
|
||||
overrides: Partial<RectangleMaskParams> = {},
|
||||
): RectangleMaskParams {
|
||||
return {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
strokeAlign: "center",
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
width: 0.4,
|
||||
height: 0.2,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function sortSegment(
|
||||
segment: [{ x: number; y: number }, { x: number; y: number }],
|
||||
): [{ x: number; y: number }, { x: number; y: number }] {
|
||||
return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [
|
||||
{ x: number; y: number },
|
||||
{ x: number; y: number },
|
||||
];
|
||||
}
|
||||
|
||||
describe("mask geometry", () => {
|
||||
test("resolves split mask center from centerX and centerY", () => {
|
||||
expect(
|
||||
getMaskSnapGeometry({
|
||||
params: buildSplitParams({
|
||||
centerX: 0.25,
|
||||
centerY: -0.5,
|
||||
rotation: 45,
|
||||
}),
|
||||
bounds,
|
||||
}),
|
||||
).toEqual({
|
||||
position: { x: 50, y: -50 },
|
||||
size: { width: 0, height: 0 },
|
||||
rotation: 45,
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves box mask center and size from centerX and centerY", () => {
|
||||
expect(
|
||||
getMaskSnapGeometry({
|
||||
params: buildRectangleParams({
|
||||
centerX: -0.25,
|
||||
centerY: 0.5,
|
||||
width: 0.5,
|
||||
height: 0.6,
|
||||
rotation: 30,
|
||||
}),
|
||||
bounds,
|
||||
}),
|
||||
).toEqual({
|
||||
position: { x: -50, y: 50 },
|
||||
size: { width: 100, height: 60 },
|
||||
rotation: 30,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a vertical split stroke segment for rotation 0", () => {
|
||||
const segment = getSplitMaskStrokeSegment({
|
||||
resolvedParams: buildSplitParams(),
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
|
||||
expect(segment).not.toBeNull();
|
||||
if (!segment) {
|
||||
throw new Error("Expected split stroke segment for rotation 0");
|
||||
}
|
||||
expect(sortSegment(segment)).toEqual([
|
||||
{ x: bounds.width / 2, y: 0 },
|
||||
{ x: bounds.width / 2, y: bounds.height },
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns a horizontal split stroke segment for rotation 90", () => {
|
||||
const segment = getSplitMaskStrokeSegment({
|
||||
resolvedParams: buildSplitParams({ rotation: 90 }),
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
|
||||
expect(segment).not.toBeNull();
|
||||
if (!segment) {
|
||||
throw new Error("Expected split stroke segment for rotation 90");
|
||||
}
|
||||
expect(sortSegment(segment)).toEqual([
|
||||
{ x: 0, y: bounds.height / 2 },
|
||||
{ x: bounds.width, y: bounds.height / 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mask snapping", () => {
|
||||
test("snaps split mask movement using the shared position pipeline", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "position",
|
||||
startParams: buildSplitParams({
|
||||
centerX: 0.03,
|
||||
centerY: -0.04,
|
||||
}),
|
||||
proposedParams: buildSplitParams({
|
||||
centerX: 0.03,
|
||||
centerY: -0.04,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.centerX).toBe(0);
|
||||
expect(result.params.centerY).toBe(0);
|
||||
expect(result.activeLines).toEqual([
|
||||
{ type: "vertical", position: 0 },
|
||||
{ type: "horizontal", position: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("snaps box mask movement against element center and edges", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "position",
|
||||
startParams: buildRectangleParams(),
|
||||
proposedParams: buildRectangleParams({
|
||||
centerX: 0.29,
|
||||
centerY: 0.03,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.centerX).toBeCloseTo(0.3);
|
||||
expect(result.params.centerY).toBe(0);
|
||||
expect(result.activeLines).toEqual([
|
||||
{ type: "vertical", position: 100 },
|
||||
{ type: "horizontal", position: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("snaps mask rotation through the shared rotation path", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "rotation",
|
||||
startParams: buildRectangleParams(),
|
||||
proposedParams: buildRectangleParams({
|
||||
rotation: 88,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.rotation).toBe(90);
|
||||
expect(result.activeLines).toEqual([]);
|
||||
});
|
||||
|
||||
test("snaps edge resize for box masks", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "right",
|
||||
startParams: buildRectangleParams(),
|
||||
proposedParams: buildRectangleParams({
|
||||
width: 0.98,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.width).toBe(1);
|
||||
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
||||
});
|
||||
|
||||
test("snaps corner resize for box masks", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "bottom-right",
|
||||
startParams: buildRectangleParams(),
|
||||
proposedParams: buildRectangleParams({
|
||||
width: 0.99,
|
||||
height: 0.495,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.width).toBe(1);
|
||||
expect(result.params.height).toBe(0.5);
|
||||
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user