summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/animation/property-registry.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-02-27 16:33:57 +0100
committerMaze Winther <mazewinther@gmail.com>2026-02-27 16:33:57 +0100
commit49426f19cd8eaed7cf6c47999b66fee7ee637a56 (patch)
tree1b37451529cfcb36e6cae79494c61b9c616077df /apps/web/src/lib/animation/property-registry.ts
parent9b94f89def4f96836c4f42a23a73e97fe647a43c (diff)
downloadopencut-49426f19cd8eaed7cf6c47999b66fee7ee637a56.tar.gz
feat: implement keyframe animation system
Add keyframe support for transform, opacity, and volume properties. Includes animation engine (interpolation, mutations, resolvers), timeline markers with selection/snapping, properties panel toggles, keyframe-aware renderer, and full undo/redo command support. Also refactor element command constructors to object params, extract timeline pixel math to pixel-utils.ts, and update cursor rules.
Diffstat (limited to 'apps/web/src/lib/animation/property-registry.ts')
-rw-r--r--apps/web/src/lib/animation/property-registry.ts230
1 files changed, 230 insertions, 0 deletions
diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts
new file mode 100644
index 00000000..44f5d638
--- /dev/null
+++ b/apps/web/src/lib/animation/property-registry.ts
@@ -0,0 +1,230 @@
+import type {
+ AnimationInterpolation,
+ AnimationPropertyPath,
+ AnimationValue,
+ AnimationValueKind,
+ DiscreteValue,
+} from "@/types/animation";
+import type { TimelineElement } from "@/types/timeline";
+import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+
+interface NumericRange {
+ min?: number;
+ max?: number;
+}
+
+interface AnimationPropertyDefinition {
+ valueKind: AnimationValueKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRange?: NumericRange;
+ supportsElement: ({ element }: { element: TimelineElement }) => boolean;
+ getValue: ({ element }: { element: TimelineElement }) => number | null;
+ setValue: ({
+ element,
+ value,
+ }: {
+ element: TimelineElement;
+ value: number;
+ }) => TimelineElement;
+}
+
+const ANIMATION_PROPERTY_REGISTRY: Record<
+ AnimationPropertyPath,
+ AnimationPropertyDefinition
+> = {
+ "transform.position.x": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.position.x : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: {
+ ...element.transform,
+ position: { ...element.transform.position, x: value },
+ },
+ }
+ : element,
+ },
+ "transform.position.y": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.position.y : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: {
+ ...element.transform,
+ position: { ...element.transform.position, y: value },
+ },
+ }
+ : element,
+ },
+ "transform.scale": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: MIN_TRANSFORM_SCALE },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.scale : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? { ...element, transform: { ...element.transform, scale: value } }
+ : element,
+ },
+ "transform.rotate": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.rotate : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? { ...element, transform: { ...element.transform, rotate: value } }
+ : element,
+ },
+ opacity: {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: 0, max: 1 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.opacity : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element) ? { ...element, opacity: value } : element,
+ },
+ volume: {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: 0, max: 1 },
+ supportsElement: ({ element }) => element.type === "audio",
+ getValue: ({ element }) =>
+ element.type === "audio" ? element.volume : null,
+ setValue: ({ element, value }) =>
+ element.type === "audio" ? { ...element, volume: value } : element,
+ },
+};
+
+export function isAnimationPropertyPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): boolean {
+ return propertyPath in ANIMATION_PROPERTY_REGISTRY;
+}
+
+export function getAnimationPropertyDefinition({
+ propertyPath,
+}: {
+ propertyPath: AnimationPropertyPath;
+}): AnimationPropertyDefinition {
+ return ANIMATION_PROPERTY_REGISTRY[propertyPath];
+}
+
+export function supportsAnimationProperty({
+ element,
+ propertyPath,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+}): boolean {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return propertyDefinition.supportsElement({ element });
+}
+
+export function getElementBaseValueForProperty({
+ element,
+ propertyPath,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+}): AnimationValue | null {
+ const definition = getAnimationPropertyDefinition({ propertyPath });
+ if (!definition.supportsElement({ element })) {
+ return null;
+ }
+ return definition.getValue({ element });
+}
+
+export function withElementBaseValueForProperty({
+ element,
+ propertyPath,
+ value,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+ value: AnimationValue;
+}): TimelineElement {
+ const coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
+ if (coercedValue === null || typeof coercedValue !== "number") {
+ return element;
+ }
+ const definition = getAnimationPropertyDefinition({ propertyPath });
+ if (!definition.supportsElement({ element })) {
+ return element;
+ }
+ return definition.setValue({ element, value: coercedValue });
+}
+
+export function getDefaultInterpolationForProperty({
+ propertyPath,
+}: {
+ propertyPath: AnimationPropertyPath;
+}): AnimationInterpolation {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return propertyDefinition.defaultInterpolation;
+}
+
+function clampNumericRange({
+ value,
+ numericRange,
+}: {
+ value: number;
+ numericRange: NumericRange | undefined;
+}): number {
+ if (!numericRange) {
+ return value;
+ }
+
+ const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
+ const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
+ return Math.min(maxValue, Math.max(minValue, value));
+}
+
+export function coerceAnimationValueForProperty({
+ propertyPath,
+ value,
+}: {
+ propertyPath: AnimationPropertyPath;
+ value: AnimationValue;
+}): AnimationValue | null {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+
+ if (propertyDefinition.valueKind === "number") {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ return clampNumericRange({
+ value,
+ numericRange: propertyDefinition.numericRange,
+ });
+ }
+
+ if (propertyDefinition.valueKind === "color") {
+ return typeof value === "string" ? value : null;
+ }
+
+ if (typeof value === "string" || typeof value === "boolean") {
+ return value as DiscreteValue;
+ }
+
+ return null;
+}