summaryrefslogtreecommitdiff
path: root/apps/web/src/animation
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/animation')
-rw-r--r--apps/web/src/animation/__tests__/animated-params.test.ts241
-rw-r--r--apps/web/src/animation/animated-params.ts83
-rw-r--r--apps/web/src/animation/effect-param-channel.ts16
-rw-r--r--apps/web/src/animation/graphic-param-channel.ts32
-rw-r--r--apps/web/src/animation/index.ts30
-rw-r--r--apps/web/src/animation/interpolation.ts2
-rw-r--r--apps/web/src/animation/keyframe-query.ts2
-rw-r--r--apps/web/src/animation/keyframes.ts48
-rw-r--r--apps/web/src/animation/path.ts22
-rw-r--r--apps/web/src/animation/property-registry.ts390
-rw-r--r--apps/web/src/animation/resolve.ts105
-rw-r--r--apps/web/src/animation/target-resolver.ts285
-rw-r--r--apps/web/src/animation/timeline-snap-points.ts35
-rw-r--r--apps/web/src/animation/types.ts10
-rw-r--r--apps/web/src/animation/values.ts61
15 files changed, 442 insertions, 920 deletions
diff --git a/apps/web/src/animation/__tests__/animated-params.test.ts b/apps/web/src/animation/__tests__/animated-params.test.ts
new file mode 100644
index 00000000..7b9d312d
--- /dev/null
+++ b/apps/web/src/animation/__tests__/animated-params.test.ts
@@ -0,0 +1,241 @@
+import { describe, expect, test } from "bun:test";
+import {
+ coerceAnimationParamValue,
+ getAnimationParamDefaultInterpolation,
+ getAnimationParamNumericRange,
+ getAnimationParamValueKind,
+} from "@/animation/animated-params";
+
+describe("animated params", () => {
+ test("snaps and clamps number params", () => {
+ expect(
+ coerceAnimationParamValue({
+ param: {
+ key: "intensity",
+ label: "Intensity",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 1,
+ step: 0.25,
+ },
+ value: 0.62,
+ }),
+ ).toBe(0.5);
+
+ expect(
+ coerceAnimationParamValue({
+ param: {
+ key: "intensity",
+ label: "Intensity",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 1,
+ step: 0.25,
+ },
+ value: 1.2,
+ }),
+ ).toBe(1);
+ });
+
+ test("rejects NaN and non-number values for number params", () => {
+ const param = {
+ key: "intensity",
+ label: "Intensity",
+ type: "number" as const,
+ default: 0,
+ min: 0,
+ max: 1,
+ step: 0.25,
+ };
+ expect(coerceAnimationParamValue({ param, value: Number.NaN })).toBeNull();
+ expect(coerceAnimationParamValue({ param, value: "0.5" })).toBeNull();
+ expect(coerceAnimationParamValue({ param, value: true })).toBeNull();
+ });
+
+ test("passthrough with step <= 0 guard", () => {
+ expect(
+ coerceAnimationParamValue({
+ param: {
+ key: "x",
+ label: "X",
+ type: "number",
+ default: 0,
+ min: 0,
+ step: 0,
+ },
+ value: 0.123,
+ }),
+ ).toBe(0.123);
+ });
+
+ test("accepts valid select values", () => {
+ const param = {
+ key: "blend",
+ label: "Blend",
+ type: "select" as const,
+ default: "normal",
+ options: [
+ { value: "normal", label: "Normal" },
+ { value: "multiply", label: "Multiply" },
+ ],
+ };
+ expect(coerceAnimationParamValue({ param, value: "normal" })).toBe("normal");
+ expect(coerceAnimationParamValue({ param, value: "multiply" })).toBe("multiply");
+ });
+
+ test("rejects select values outside the allowed options", () => {
+ expect(
+ coerceAnimationParamValue({
+ param: {
+ key: "blend",
+ label: "Blend",
+ type: "select",
+ default: "normal",
+ options: [
+ { value: "normal", label: "Normal" },
+ { value: "multiply", label: "Multiply" },
+ ],
+ },
+ value: "screen",
+ }),
+ ).toBeNull();
+ });
+
+ test("rejects non-string select values", () => {
+ const param = {
+ key: "blend",
+ label: "Blend",
+ type: "select" as const,
+ default: "normal",
+ options: [{ value: "normal", label: "Normal" }],
+ };
+ expect(coerceAnimationParamValue({ param, value: 42 })).toBeNull();
+ expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
+ expect(coerceAnimationParamValue({ param, value: undefined })).toBeNull();
+ });
+
+ test("boolean params accept booleans and reject other types", () => {
+ const param = {
+ key: "visible",
+ label: "Visible",
+ type: "boolean" as const,
+ default: true,
+ };
+ expect(coerceAnimationParamValue({ param, value: true })).toBe(true);
+ expect(coerceAnimationParamValue({ param, value: false })).toBe(false);
+ expect(coerceAnimationParamValue({ param, value: 1 })).toBeNull();
+ expect(coerceAnimationParamValue({ param, value: "true" })).toBeNull();
+ });
+
+ test("color params accept strings and reject other types", () => {
+ const param = {
+ key: "fill",
+ label: "Fill",
+ type: "color" as const,
+ default: "#ffffff",
+ };
+ expect(coerceAnimationParamValue({ param, value: "#ff0000" })).toBe("#ff0000");
+ expect(coerceAnimationParamValue({ param, value: 0xff0000 })).toBeNull();
+ expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
+ });
+
+ test("getAnimationParamValueKind maps param type to binding kind", () => {
+ expect(
+ getAnimationParamValueKind({
+ param: {
+ key: "n",
+ label: "N",
+ type: "number",
+ default: 0,
+ min: 0,
+ step: 1,
+ },
+ }),
+ ).toBe("number");
+ expect(
+ getAnimationParamValueKind({
+ param: { key: "c", label: "C", type: "color", default: "#fff" },
+ }),
+ ).toBe("color");
+ expect(
+ getAnimationParamValueKind({
+ param: { key: "b", label: "B", type: "boolean", default: false },
+ }),
+ ).toBe("discrete");
+ expect(
+ getAnimationParamValueKind({
+ param: {
+ key: "s",
+ label: "S",
+ type: "select",
+ default: "a",
+ options: [{ value: "a", label: "A" }],
+ },
+ }),
+ ).toBe("discrete");
+ });
+
+ test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => {
+ expect(
+ getAnimationParamDefaultInterpolation({
+ param: {
+ key: "n",
+ label: "N",
+ type: "number",
+ default: 0,
+ min: 0,
+ step: 1,
+ },
+ }),
+ ).toBe("linear");
+ expect(
+ getAnimationParamDefaultInterpolation({
+ param: { key: "c", label: "C", type: "color", default: "#fff" },
+ }),
+ ).toBe("linear");
+ expect(
+ getAnimationParamDefaultInterpolation({
+ param: { key: "b", label: "B", type: "boolean", default: false },
+ }),
+ ).toBe("hold");
+ expect(
+ getAnimationParamDefaultInterpolation({
+ param: {
+ key: "s",
+ label: "S",
+ type: "select",
+ default: "a",
+ options: [{ value: "a", label: "A" }],
+ },
+ }),
+ ).toBe("hold");
+ });
+
+ test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => {
+ expect(
+ getAnimationParamNumericRange({
+ param: {
+ key: "intensity",
+ label: "Intensity",
+ type: "number",
+ default: 0.5,
+ min: 0,
+ max: 1,
+ step: 0.1,
+ },
+ }),
+ ).toEqual({ min: 0, max: 1, step: 0.1 });
+ expect(
+ getAnimationParamNumericRange({
+ param: { key: "c", label: "C", type: "color", default: "#fff" },
+ }),
+ ).toBeUndefined();
+ expect(
+ getAnimationParamNumericRange({
+ param: { key: "b", label: "B", type: "boolean", default: false },
+ }),
+ ).toBeUndefined();
+ });
+});
diff --git a/apps/web/src/animation/animated-params.ts b/apps/web/src/animation/animated-params.ts
new file mode 100644
index 00000000..bc27b15d
--- /dev/null
+++ b/apps/web/src/animation/animated-params.ts
@@ -0,0 +1,83 @@
+import { snapToStep } from "@/utils/math";
+import type { ParamDefinition } from "@/params";
+import type { DynamicAnimationPathValue, NumericSpec } from "./types";
+
+export function getAnimationParamValueKind({
+ param,
+}: {
+ param: ParamDefinition;
+}): "number" | "color" | "discrete" {
+ if (param.type === "number") {
+ return "number";
+ }
+
+ if (param.type === "color") {
+ return "color";
+ }
+
+ return "discrete";
+}
+
+export function getAnimationParamDefaultInterpolation({
+ param,
+}: {
+ param: ParamDefinition;
+}): "linear" | "hold" {
+ return param.type === "number" || param.type === "color" ? "linear" : "hold";
+}
+
+export function getAnimationParamNumericRange({
+ param,
+}: {
+ param: ParamDefinition;
+}): NumericSpec | undefined {
+ if (param.type !== "number") {
+ return undefined;
+ }
+
+ return {
+ min: param.min,
+ max: param.max,
+ step: param.step,
+ };
+}
+
+/**
+ * `coerceAnimationParamValue` accepts `unknown` rather than a narrow
+ * `DynamicAnimationPathValue` because it doubles as a runtime gate for
+ * untrusted inputs (persisted state, paste payloads, programmatic updates).
+ * The caller does not need to pre-validate; null means "this value cannot live
+ * on this param".
+ */
+export function coerceAnimationParamValue({
+ param,
+ value,
+}: {
+ param: ParamDefinition;
+ value: unknown;
+}): DynamicAnimationPathValue | null {
+ if (param.type === "number") {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ const steppedValue = snapToStep({ value, step: param.step });
+ const minValue = param.min;
+ const maxValue = param.max ?? Number.POSITIVE_INFINITY;
+ return Math.min(maxValue, Math.max(minValue, steppedValue));
+ }
+
+ if (param.type === "color") {
+ return typeof value === "string" ? value : null;
+ }
+
+ if (param.type === "boolean") {
+ return typeof value === "boolean" ? value : null;
+ }
+
+ if (typeof value !== "string") {
+ return null;
+ }
+
+ return param.options.some((option) => option.value === value) ? value : null;
+}
diff --git a/apps/web/src/animation/effect-param-channel.ts b/apps/web/src/animation/effect-param-channel.ts
index c635bb82..e1c442f7 100644
--- a/apps/web/src/animation/effect-param-channel.ts
+++ b/apps/web/src/animation/effect-param-channel.ts
@@ -1,9 +1,8 @@
-import type { ParamValues } from "@/params";
-import type { Effect } from "@/effects/types";
import type {
ElementAnimations,
EffectParamPath,
} from "@/animation/types";
+import type { ParamValues } from "@/params";
import { removeElementKeyframe } from "./keyframes";
import { resolveAnimationPathValueAtTime } from "./resolve";
@@ -56,23 +55,26 @@ export function parseEffectParamPath({
}
export function resolveEffectParamsAtTime({
- effect,
+ effectId,
+ params,
animations,
localTime,
}: {
- effect: Effect;
+ effectId: string;
+ params: ParamValues;
animations: ElementAnimations | undefined;
localTime: number;
}): ParamValues {
+ const safeLocalTime = Math.max(0, localTime);
const resolved: ParamValues = {};
- for (const [paramKey, staticValue] of Object.entries(effect.params)) {
- const path = buildEffectParamPath({ effectId: effect.id, paramKey });
+ for (const [paramKey, staticValue] of Object.entries(params)) {
+ const path = buildEffectParamPath({ effectId, paramKey });
resolved[paramKey] = animations?.bindings[path]
? resolveAnimationPathValueAtTime({
animations,
propertyPath: path,
- localTime,
+ localTime: safeLocalTime,
fallbackValue: staticValue,
})
: staticValue;
diff --git a/apps/web/src/animation/graphic-param-channel.ts b/apps/web/src/animation/graphic-param-channel.ts
index ebf0f940..d0bfdded 100644
--- a/apps/web/src/animation/graphic-param-channel.ts
+++ b/apps/web/src/animation/graphic-param-channel.ts
@@ -2,11 +2,7 @@ import type {
ElementAnimations,
GraphicParamPath,
} from "@/animation/types";
-import type { ParamValues } from "@/params";
-import {
- getGraphicDefinition,
- resolveGraphicParams,
-} from "@/graphics";
+import type { ParamDefinition, ParamValues } from "@/params";
import { resolveAnimationPathValueAtTime } from "./resolve";
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
@@ -39,33 +35,29 @@ export function parseGraphicParamPath({
}
export function resolveGraphicParamsAtTime({
- element,
+ params,
+ definitions,
+ animations,
localTime,
}: {
- element: {
- definitionId: string;
- params: ParamValues;
- animations?: ElementAnimations;
- };
+ params: ParamValues;
+ definitions: ParamDefinition[];
+ animations?: ElementAnimations;
localTime: number;
}): ParamValues {
- const definition = getGraphicDefinition({
- definitionId: element.definitionId,
- });
- const baseParams = resolveGraphicParams(definition, element.params);
- const resolved: ParamValues = { ...baseParams };
+ const resolved: ParamValues = { ...params };
- for (const param of definition.params) {
+ for (const param of definitions) {
const path = buildGraphicParamPath({ paramKey: param.key });
- if (!element.animations?.bindings[path]) {
+ if (!animations?.bindings[path]) {
continue;
}
resolved[param.key] = resolveAnimationPathValueAtTime({
- animations: element.animations,
+ animations,
propertyPath: path,
localTime: Math.max(0, localTime),
- fallbackValue: baseParams[param.key] ?? param.default,
+ fallbackValue: params[param.key] ?? param.default,
});
}
diff --git a/apps/web/src/animation/index.ts b/apps/web/src/animation/index.ts
index 384f3d17..af3b910d 100644
--- a/apps/web/src/animation/index.ts
+++ b/apps/web/src/animation/index.ts
@@ -16,32 +16,15 @@ export {
setChannel,
splitAnimationsAtTime,
updateScalarKeyframeCurve,
- upsertElementKeyframe,
upsertPathKeyframe,
} from "./keyframes";
export {
getElementLocalTime,
resolveAnimationPathValueAtTime,
- resolveColorAtTime,
- resolveNumberAtTime,
- resolveOpacityAtTime,
- resolveTransformAtTime,
} from "./resolve";
export {
- coerceAnimationValueForProperty,
- getAnimationPropertyDefinition,
- getDefaultInterpolationForProperty,
- getElementBaseValueForProperty,
- isAnimationPropertyPath,
- supportsAnimationProperty,
- type AnimationPropertyDefinition,
- type NumericSpec,
- withElementBaseValueForProperty,
-} from "./property-registry";
-
-export {
getElementKeyframes,
getKeyframeById,
getKeyframeAtTime,
@@ -76,15 +59,6 @@ export {
} from "./effect-param-channel";
export {
- isAnimationPath,
- coerceAnimationValueForParam,
- resolveAnimationTarget,
- getParamValueKind,
- getParamDefaultInterpolation,
- type AnimationPathDescriptor,
-} from "./target-resolver";
-
-export {
getGroupKeyframesAtTime,
hasGroupKeyframeAtTime,
type GroupKeyframeRef,
@@ -94,3 +68,7 @@ export {
type EasingMode,
getEasingModeForKind,
} from "./binding-values";
+
+export {
+ isAnimationPath,
+} from "./path";
diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts
index 277bfbb7..6bab66c4 100644
--- a/apps/web/src/animation/interpolation.ts
+++ b/apps/web/src/animation/interpolation.ts
@@ -8,13 +8,13 @@ import type {
ScalarAnimationKey,
ScalarSegmentType,
} from "@/animation/types";
-import { clamp } from "@/utils/math";
import {
getBezierPoint,
getDefaultLeftHandle,
getDefaultRightHandle,
solveBezierProgressForTime,
} from "./bezier";
+import { clamp } from "@/utils/math";
function byTimeAscending({
leftTime,
diff --git a/apps/web/src/animation/keyframe-query.ts b/apps/web/src/animation/keyframe-query.ts
index 231aa5f2..e65db881 100644
--- a/apps/web/src/animation/keyframe-query.ts
+++ b/apps/web/src/animation/keyframe-query.ts
@@ -13,7 +13,7 @@ import {
getChannelValueAtTime,
getScalarSegmentInterpolation,
} from "./interpolation";
-import { isAnimationPath } from "./target-resolver";
+import { isAnimationPath } from "./path";
function getBindingFallbackValue({
channel,
diff --git a/apps/web/src/animation/keyframes.ts b/apps/web/src/animation/keyframes.ts
index 12dc777d..ff0cef8f 100644
--- a/apps/web/src/animation/keyframes.ts
+++ b/apps/web/src/animation/keyframes.ts
@@ -4,7 +4,6 @@ import type {
AnimationChannel,
AnimationInterpolation,
AnimationPath,
- AnimationPropertyPath,
AnimationValue,
DiscreteAnimationChannel,
DiscreteAnimationKey,
@@ -14,7 +13,6 @@ import type {
ScalarCurveKeyframePatch,
ScalarSegmentType,
} from "@/animation/types";
-import { generateUUID } from "@/utils/id";
import {
cloneAnimationBinding,
createAnimationBinding,
@@ -31,10 +29,7 @@ import {
getScalarSegmentInterpolation,
normalizeChannel,
} from "./interpolation";
-import {
- coerceAnimationValueForProperty,
- getAnimationPropertyDefinition,
-} from "./property-registry";
+import { generateUUID } from "@/utils/id";
function isNearlySameTime({
leftTime,
@@ -527,47 +522,6 @@ export function upsertPathKeyframe({
});
}
-export function upsertElementKeyframe({
- animations,
- propertyPath,
- time,
- value,
- interpolation,
- keyframeId,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- time: number;
- value: AnimationValue;
- interpolation?: AnimationInterpolation;
- keyframeId?: string;
-}): ElementAnimations | undefined {
- const coercedValue = coerceAnimationValueForProperty({
- propertyPath,
- value,
- });
- if (coercedValue === null) {
- return animations;
- }
-
- const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
- return upsertPathKeyframe({
- animations,
- propertyPath,
- time,
- value: coercedValue,
- interpolation,
- keyframeId,
- kind: propertyDefinition.kind,
- defaultInterpolation: propertyDefinition.defaultInterpolation,
- coerceValue: ({ value: nextValue }) =>
- coerceAnimationValueForProperty({
- propertyPath,
- value: nextValue,
- }),
- });
-}
-
export function upsertKeyframe({
channel,
time,
diff --git a/apps/web/src/animation/path.ts b/apps/web/src/animation/path.ts
new file mode 100644
index 00000000..69e5e228
--- /dev/null
+++ b/apps/web/src/animation/path.ts
@@ -0,0 +1,22 @@
+import type { AnimationPath, AnimationPropertyPath } from "@/animation/types";
+import { ANIMATION_PROPERTY_PATHS } from "./types";
+import { isEffectParamPath } from "./effect-param-channel";
+import { isGraphicParamPath } from "./graphic-param-channel";
+
+const ANIMATION_PROPERTY_PATH_SET = new Set<string>(ANIMATION_PROPERTY_PATHS);
+
+export function isAnimationPropertyPath(
+ propertyPath: string,
+): propertyPath is AnimationPropertyPath {
+ return ANIMATION_PROPERTY_PATH_SET.has(propertyPath);
+}
+
+export function isAnimationPath(
+ propertyPath: string,
+): propertyPath is AnimationPath {
+ return (
+ isAnimationPropertyPath(propertyPath) ||
+ isGraphicParamPath(propertyPath) ||
+ isEffectParamPath(propertyPath)
+ );
+}
diff --git a/apps/web/src/animation/property-registry.ts b/apps/web/src/animation/property-registry.ts
deleted file mode 100644
index aacb9972..00000000
--- a/apps/web/src/animation/property-registry.ts
+++ /dev/null
@@ -1,390 +0,0 @@
-import type {
- AnimationBindingKind,
- AnimationInterpolation,
- AnimationPropertyPath,
- AnimationValue,
-} from "@/animation/types";
-import { parseColorToLinearRgba } from "./binding-values";
-import type { TimelineElement } from "@/timeline";
-import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
-import {
- CORNER_RADIUS_MAX,
- CORNER_RADIUS_MIN,
-} from "@/text/background";
-import {
- canElementHaveAudio,
- isVisualElement,
-} from "@/timeline/element-utils";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
-import { DEFAULTS } from "@/timeline/defaults";
-import { snapToStep } from "@/utils/math";
-
-export interface NumericSpec {
- min?: number;
- max?: number;
- step?: number;
-}
-
-export interface AnimationPropertyDefinition {
- kind: AnimationBindingKind;
- defaultInterpolation: AnimationInterpolation;
- numericRanges?: Partial<Record<string, NumericSpec>>;
- supportsElement: ({ element }: { element: TimelineElement }) => boolean;
- getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
- coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
- setValue: ({
- element,
- value,
- }: {
- element: TimelineElement;
- value: AnimationValue;
- }) => TimelineElement;
-}
-
-function applyNumericSpec({
- value,
- numericRange,
-}: {
- value: number;
- numericRange: NumericSpec | undefined;
-}): number {
- if (!numericRange) {
- return value;
- }
-
- const steppedValue =
- numericRange.step != null
- ? snapToStep({ value, step: numericRange.step })
- : value;
- const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
- const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
- return Math.min(maxValue, Math.max(minValue, steppedValue));
-}
-
-function coerceNumberValue({
- value,
- numericRange,
-}: {
- value: AnimationValue;
- numericRange?: NumericSpec;
-}): number | null {
- if (typeof value !== "number" || Number.isNaN(value)) {
- return null;
- }
-
- return applyNumericSpec({ value, numericRange });
-}
-
-function coerceColorValue({
- value,
-}: {
- value: AnimationValue;
-}): string | null {
- return typeof value === "string" && parseColorToLinearRgba({ color: value })
- ? value
- : null;
-}
-
-function createNumberPropertyDefinition({
- numericRange,
- supportsElement,
- getValue,
- setValue,
-}: {
- numericRange?: NumericSpec;
- supportsElement: AnimationPropertyDefinition["supportsElement"];
- getValue: AnimationPropertyDefinition["getValue"];
- setValue: AnimationPropertyDefinition["setValue"];
-}): AnimationPropertyDefinition {
- return {
- kind: "number",
- defaultInterpolation: "linear",
- numericRanges: numericRange ? { value: numericRange } : undefined,
- supportsElement,
- getValue,
- coerceValue: ({ value }) =>
- coerceNumberValue({
- value,
- numericRange,
- }),
- setValue,
- };
-}
-
-const ANIMATION_PROPERTY_REGISTRY: Record<
- AnimationPropertyPath,
- AnimationPropertyDefinition
-> = {
- "transform.positionX": createNumberPropertyDefinition({
- numericRange: { step: 1 },
- 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 as number },
- },
- }
- : element,
- }),
- "transform.positionY": createNumberPropertyDefinition({
- numericRange: { step: 1 },
- 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 as number },
- },
- }
- : element,
- }),
- "transform.scaleX": createNumberPropertyDefinition({
- numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.scaleX : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: { ...element.transform, scaleX: value as number },
- }
- : element,
- }),
- "transform.scaleY": createNumberPropertyDefinition({
- numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.scaleY : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: { ...element.transform, scaleY: value as number },
- }
- : element,
- }),
- "transform.rotate": createNumberPropertyDefinition({
- numericRange: { min: -360, max: 360, step: 1 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.rotate : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: { ...element.transform, rotate: value as number },
- }
- : element,
- }),
- opacity: createNumberPropertyDefinition({
- numericRange: { min: 0, max: 1, step: 0.01 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.opacity : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? { ...element, opacity: value as number }
- : element,
- }),
- volume: createNumberPropertyDefinition({
- numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
- supportsElement: ({ element }) => canElementHaveAudio(element),
- getValue: ({ element }) =>
- canElementHaveAudio(element) ? element.volume ?? 0 : null,
- setValue: ({ element, value }) =>
- canElementHaveAudio(element)
- ? { ...element, volume: value as number }
- : element,
- }),
- color: {
- kind: "color",
- defaultInterpolation: "linear",
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) => (element.type === "text" ? element.color : null),
- coerceValue: ({ value }) => coerceColorValue({ value }),
- setValue: ({ element, value }) =>
- element.type === "text"
- ? { ...element, color: value as string }
- : element,
- },
- "background.color": {
- kind: "color",
- defaultInterpolation: "linear",
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text" ? element.background.color : null,
- coerceValue: ({ value }) => coerceColorValue({ value }),
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, color: value as string },
- }
- : element,
- },
- "background.paddingX": createNumberPropertyDefinition({
- numericRange: { min: 0, step: 1 },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, paddingX: value as number },
- }
- : element,
- }),
- "background.paddingY": createNumberPropertyDefinition({
- numericRange: { min: 0, step: 1 },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, paddingY: value as number },
- }
- : element,
- }),
- "background.offsetX": createNumberPropertyDefinition({
- numericRange: { step: 1 },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, offsetX: value as number },
- }
- : element,
- }),
- "background.offsetY": createNumberPropertyDefinition({
- numericRange: { step: 1 },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, offsetY: value as number },
- }
- : element,
- }),
- "background.cornerRadius": createNumberPropertyDefinition({
- numericRange: {
- min: CORNER_RADIUS_MIN,
- max: CORNER_RADIUS_MAX,
- step: 1,
- },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, cornerRadius: value as number },
- }
- : element,
- }),
-};
-
-export function isAnimationPropertyPath(
- propertyPath: string,
-): propertyPath is AnimationPropertyPath {
- return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
-}
-
-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 definition = getAnimationPropertyDefinition({ propertyPath });
- const coercedValue = definition.coerceValue({ value });
- if (coercedValue === null || !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;
-}
-
-export function coerceAnimationValueForProperty({
- propertyPath,
- value,
-}: {
- propertyPath: AnimationPropertyPath;
- value: AnimationValue;
-}): AnimationValue | null {
- const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
- return propertyDefinition.coerceValue({ value });
-}
diff --git a/apps/web/src/animation/resolve.ts b/apps/web/src/animation/resolve.ts
index 1b51c126..19685cea 100644
--- a/apps/web/src/animation/resolve.ts
+++ b/apps/web/src/animation/resolve.ts
@@ -1,12 +1,8 @@
import type {
- AnimationColorPropertyPath,
- AnimationNumericPropertyPath,
AnimationPath,
- AnimationPropertyPath,
AnimationValueForPath,
ElementAnimations,
} from "@/animation/types";
-import type { Transform } from "@/rendering";
import {
type AnimationComponentValue,
composeAnimationValue,
@@ -37,107 +33,6 @@ export function getElementLocalTime({
return localTime;
}
-export function resolveTransformAtTime({
- baseTransform,
- animations,
- localTime,
-}: {
- baseTransform: Transform;
- animations: ElementAnimations | undefined;
- localTime: number;
-}): Transform {
- const safeLocalTime = Math.max(0, localTime);
- return {
- position: {
- x: resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "transform.positionX",
- localTime: safeLocalTime,
- fallbackValue: baseTransform.position.x,
- }),
- y: resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "transform.positionY",
- localTime: safeLocalTime,
- fallbackValue: baseTransform.position.y,
- }),
- },
- scaleX: resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "transform.scaleX",
- localTime: safeLocalTime,
- fallbackValue: baseTransform.scaleX,
- }),
- scaleY: resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "transform.scaleY",
- localTime: safeLocalTime,
- fallbackValue: baseTransform.scaleY,
- }),
- rotate: resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "transform.rotate",
- localTime: safeLocalTime,
- fallbackValue: baseTransform.rotate,
- }),
- };
-}
-
-export function resolveOpacityAtTime({
- baseOpacity,
- animations,
- localTime,
-}: {
- baseOpacity: number;
- animations: ElementAnimations | undefined;
- localTime: number;
-}): number {
- return resolveAnimationPathValueAtTime({
- animations,
- propertyPath: "opacity",
- localTime: Math.max(0, localTime),
- fallbackValue: baseOpacity,
- });
-}
-
-export function resolveNumberAtTime({
- baseValue,
- animations,
- propertyPath,
- localTime,
-}: {
- baseValue: number;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationNumericPropertyPath;
- localTime: number;
-}): number {
- return resolveAnimationPathValueAtTime({
- animations,
- propertyPath,
- localTime: Math.max(0, localTime),
- fallbackValue: baseValue,
- });
-}
-
-export function resolveColorAtTime({
- baseColor,
- animations,
- propertyPath,
- localTime,
-}: {
- baseColor: string;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationColorPropertyPath;
- localTime: number;
-}): string {
- return resolveAnimationPathValueAtTime({
- animations,
- propertyPath,
- localTime: Math.max(0, localTime),
- fallbackValue: baseColor,
- });
-}
-
export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
animations,
propertyPath,
diff --git a/apps/web/src/animation/target-resolver.ts b/apps/web/src/animation/target-resolver.ts
deleted file mode 100644
index 32e7917c..00000000
--- a/apps/web/src/animation/target-resolver.ts
+++ /dev/null
@@ -1,285 +0,0 @@
-import type {
- AnimationBindingKind,
- AnimationInterpolation,
- AnimationPath,
- AnimationValue,
-} from "@/animation/types";
-import {
- parseEffectParamPath,
- isEffectParamPath,
-} from "@/animation/effect-param-channel";
-import {
- isGraphicParamPath,
- parseGraphicParamPath,
-} from "@/animation/graphic-param-channel";
-import type { ParamDefinition } from "@/params";
-import { effectsRegistry, registerDefaultEffects } from "@/effects";
-import { getGraphicDefinition } from "@/graphics";
-import type { TimelineElement } from "@/timeline";
-import { isVisualElement } from "@/timeline/element-utils";
-import { snapToStep } from "@/utils/math";
-import {
- coerceAnimationValueForProperty,
- getAnimationPropertyDefinition,
- getElementBaseValueForProperty,
- isAnimationPropertyPath,
- type NumericSpec,
- withElementBaseValueForProperty,
-} from "./property-registry";
-import { parseColorToLinearRgba } from "./binding-values";
-
-export interface AnimationPathDescriptor {
- kind: AnimationBindingKind;
- defaultInterpolation: AnimationInterpolation;
- numericRanges?: Partial<Record<string, NumericSpec>>;
- coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
- getBaseValue: () => AnimationValue | null;
- setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
-}
-
-export function getParamValueKind({
- param,
-}: {
- param: ParamDefinition;
-}): AnimationBindingKind {
- if (param.type === "number") {
- return "number";
- }
-
- if (param.type === "color") {
- return "color";
- }
-
- return "discrete";
-}
-
-export function getParamDefaultInterpolation({
- param,
-}: {
- param: ParamDefinition;
-}): AnimationInterpolation {
- return param.type === "number" || param.type === "color" ? "linear" : "hold";
-}
-
-function getParamNumericRange({
- param,
-}: {
- param: ParamDefinition;
-}): Partial<Record<string, NumericSpec>> | undefined {
- if (param.type !== "number") {
- return undefined;
- }
-
- return {
- value: {
- min: param.min,
- max: param.max,
- step: param.step,
- },
- };
-}
-
-export function coerceAnimationValueForParam({
- param,
- value,
-}: {
- param: ParamDefinition;
- value: AnimationValue;
-}): number | string | boolean | null {
- if (param.type === "number") {
- if (typeof value !== "number" || Number.isNaN(value)) {
- return null;
- }
-
- const steppedValue = snapToStep({ value, step: param.step });
- const minValue = param.min;
- const maxValue = param.max ?? Number.POSITIVE_INFINITY;
- return Math.min(maxValue, Math.max(minValue, steppedValue));
- }
-
- if (param.type === "color") {
- return typeof value === "string" ? value : null;
- }
-
- if (param.type === "boolean") {
- return typeof value === "boolean" ? value : null;
- }
-
- if (typeof value !== "string") {
- return null;
- }
-
- return param.options.some((option) => option.value === value) ? value : null;
-}
-
-function buildGraphicParamDescriptor({
- element,
- paramKey,
-}: {
- element: TimelineElement;
- paramKey: string;
-}): AnimationPathDescriptor | null {
- if (element.type !== "graphic") {
- return null;
- }
-
- const definition = getGraphicDefinition({
- definitionId: element.definitionId,
- });
- const param = definition.params.find((candidate) => candidate.key === paramKey);
- if (!param) {
- return null;
- }
-
- return {
- kind: getParamValueKind({ param }),
- defaultInterpolation: getParamDefaultInterpolation({ param }),
- numericRanges: getParamNumericRange({ param }),
- coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
- getBaseValue: () => element.params[param.key] ?? param.default,
- setBaseValue: ({ value }) => {
- const coercedValue = coerceAnimationValueForParam({ param, value });
- if (coercedValue === null) {
- return element;
- }
-
- return {
- ...element,
- params: {
- ...element.params,
- [param.key]: coercedValue,
- },
- };
- },
- };
-}
-
-function buildEffectParamDescriptor({
- element,
- effectId,
- paramKey,
-}: {
- element: TimelineElement;
- effectId: string;
- paramKey: string;
-}): AnimationPathDescriptor | null {
- if (!isVisualElement(element)) {
- return null;
- }
-
- const effect = element.effects?.find((candidate) => candidate.id === effectId);
- if (!effect) {
- return null;
- }
-
- registerDefaultEffects();
- const definition = effectsRegistry.get(effect.type);
- const param = definition.params.find((candidate) => candidate.key === paramKey);
- if (!param) {
- return null;
- }
-
- return {
- kind: getParamValueKind({ param }),
- defaultInterpolation: getParamDefaultInterpolation({ param }),
- numericRanges: getParamNumericRange({ param }),
- coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
- getBaseValue: () => effect.params[param.key] ?? param.default,
- setBaseValue: ({ value }) => {
- const coercedValue = coerceAnimationValueForParam({ param, value });
- if (coercedValue === null) {
- return element;
- }
-
- return {
- ...element,
- effects:
- element.effects?.map((candidate) =>
- candidate.id !== effectId
- ? candidate
- : {
- ...candidate,
- params: {
- ...candidate.params,
- [param.key]: coercedValue,
- },
- },
- ) ?? element.effects,
- };
- },
- };
-}
-
-export function isAnimationPath(
- propertyPath: string,
-): propertyPath is AnimationPath {
- return (
- isAnimationPropertyPath(propertyPath) ||
- isGraphicParamPath(propertyPath) ||
- isEffectParamPath(propertyPath)
- );
-}
-
-export function resolveAnimationTarget({
- element,
- path,
-}: {
- element: TimelineElement;
- path: AnimationPath;
-}): AnimationPathDescriptor | null {
- if (isAnimationPropertyPath(path)) {
- const propertyDefinition = getAnimationPropertyDefinition({
- propertyPath: path,
- });
- if (!propertyDefinition.supportsElement({ element })) {
- return null;
- }
-
- return {
- kind: propertyDefinition.kind,
- defaultInterpolation: propertyDefinition.defaultInterpolation,
- numericRanges: propertyDefinition.numericRanges,
- coerceValue: ({ value }) =>
- coerceAnimationValueForProperty({
- propertyPath: path,
- value,
- }),
- getBaseValue: () =>
- getElementBaseValueForProperty({
- element,
- propertyPath: path,
- }),
- setBaseValue: ({ value }) => {
- const coercedValue = propertyDefinition.coerceValue({ value });
- if (coercedValue === null) {
- return element;
- }
-
- return withElementBaseValueForProperty({
- element,
- propertyPath: path,
- value: coercedValue,
- });
- },
- };
- }
-
- const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
- if (graphicParamTarget) {
- return buildGraphicParamDescriptor({
- element,
- paramKey: graphicParamTarget.paramKey,
- });
- }
-
- const effectParamTarget = parseEffectParamPath({ propertyPath: path });
- if (effectParamTarget) {
- return buildEffectParamDescriptor({
- element,
- effectId: effectParamTarget.effectId,
- paramKey: effectParamTarget.paramKey,
- });
- }
-
- return null;
-}
diff --git a/apps/web/src/animation/timeline-snap-points.ts b/apps/web/src/animation/timeline-snap-points.ts
deleted file mode 100644
index f7bf549a..00000000
--- a/apps/web/src/animation/timeline-snap-points.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { getElementKeyframes } from "@/animation";
-import type { SceneTracks } from "@/timeline";
-import type { SnapPoint } from "@/timeline/snapping";
-
-export function getAnimationKeyframeSnapPointsForTimeline({
- tracks,
- excludeElementIds,
-}: {
- tracks: SceneTracks;
- excludeElementIds?: Set<string>;
-}): SnapPoint[] {
- const snapPoints: SnapPoint[] = [];
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
-
- for (const track of orderedTracks) {
- for (const element of track.elements) {
- if (excludeElementIds?.has(element.id)) {
- continue;
- }
-
- for (const keyframe of getElementKeyframes({
- animations: element.animations,
- })) {
- snapPoints.push({
- time: element.startTime + keyframe.time,
- type: "keyframe",
- elementId: element.id,
- trackId: track.id,
- });
- }
- }
- }
-
- return snapPoints;
-}
diff --git a/apps/web/src/animation/types.ts b/apps/web/src/animation/types.ts
index 3afddfa1..7e241994 100644
--- a/apps/web/src/animation/types.ts
+++ b/apps/web/src/animation/types.ts
@@ -1,5 +1,3 @@
-import type { ParamValues } from "@/params";
-
export const ANIMATION_PROPERTY_PATHS = [
"transform.positionX",
"transform.positionY",
@@ -50,7 +48,13 @@ export interface AnimationPropertyValueMap {
"background.offsetY": number;
"background.cornerRadius": number;
}
-export type DynamicAnimationPathValue = ParamValues[string];
+export type DynamicAnimationPathValue = number | string | boolean;
+
+export interface NumericSpec {
+ min?: number;
+ max?: number;
+ step?: number;
+}
export type AnimationValueForPath<TPath extends AnimationPath> =
TPath extends AnimationPropertyPath
? AnimationPropertyValueMap[TPath]
diff --git a/apps/web/src/animation/values.ts b/apps/web/src/animation/values.ts
new file mode 100644
index 00000000..7733ec4c
--- /dev/null
+++ b/apps/web/src/animation/values.ts
@@ -0,0 +1,61 @@
+import type {
+ AnimationColorPropertyPath,
+ AnimationNumericPropertyPath,
+ ElementAnimations,
+} from "./types";
+import { resolveAnimationPathValueAtTime } from "./resolve";
+
+export function resolveOpacityAtTime({
+ baseOpacity,
+ animations,
+ localTime,
+}: {
+ baseOpacity: number;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+}): number {
+ return resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "opacity",
+ localTime: Math.max(0, localTime),
+ fallbackValue: baseOpacity,
+ });
+}
+
+export function resolveNumberAtTime({
+ baseValue,
+ animations,
+ propertyPath,
+ localTime,
+}: {
+ baseValue: number;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationNumericPropertyPath;
+ localTime: number;
+}): number {
+ return resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath,
+ localTime: Math.max(0, localTime),
+ fallbackValue: baseValue,
+ });
+}
+
+export function resolveColorAtTime({
+ baseColor,
+ animations,
+ propertyPath,
+ localTime,
+}: {
+ baseColor: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationColorPropertyPath;
+ localTime: number;
+}): string {
+ return resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath,
+ localTime: Math.max(0, localTime),
+ fallbackValue: baseColor,
+ });
+}