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.ts41
-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.ts2
-rw-r--r--apps/web/src/animation/path.ts22
-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.ts36
-rw-r--r--apps/web/src/animation/types.ts8
-rw-r--r--apps/web/src/animation/values.ts61
14 files changed, 454 insertions, 482 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..33e637c3 100644
--- a/apps/web/src/animation/index.ts
+++ b/apps/web/src/animation/index.ts
@@ -23,25 +23,9 @@ export {
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 +60,6 @@ export {
} from "./effect-param-channel";
export {
- isAnimationPath,
- coerceAnimationValueForParam,
- resolveAnimationTarget,
- getParamValueKind,
- getParamDefaultInterpolation,
- type AnimationPathDescriptor,
-} from "./target-resolver";
-
-export {
getGroupKeyframesAtTime,
hasGroupKeyframeAtTime,
type GroupKeyframeRef,
@@ -94,3 +69,19 @@ export {
type EasingMode,
getEasingModeForKind,
} from "./binding-values";
+
+export {
+ isAnimationPath,
+ isAnimationPropertyPath,
+} from "./path";
+
+export {
+ coerceAnimationValueForProperty,
+ getAnimationPropertyDefinition,
+ getDefaultInterpolationForProperty,
+ getElementBaseValueForProperty,
+ supportsAnimationProperty,
+ type AnimationPropertyDefinition,
+ type NumericSpec,
+ withElementBaseValueForProperty,
+} from "./property-registry";
diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts
index 2169024a..5d5212a6 100644
--- a/apps/web/src/animation/interpolation.ts
+++ b/apps/web/src/animation/interpolation.ts
@@ -8,7 +8,6 @@ import type {
ScalarAnimationKey,
ScalarSegmentType,
} from "@/animation/types";
-import { clamp } from "@/utils/math";
import { mediaTime } from "@/wasm";
import {
getBezierPoint,
@@ -16,6 +15,7 @@ import {
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 bdfe7c4c..70f9b5db 100644
--- a/apps/web/src/animation/keyframes.ts
+++ b/apps/web/src/animation/keyframes.ts
@@ -14,7 +14,6 @@ import type {
ScalarCurveKeyframePatch,
ScalarSegmentType,
} from "@/animation/types";
-import { generateUUID } from "@/utils/id";
import {
cloneAnimationBinding,
createAnimationBinding,
@@ -41,6 +40,7 @@ import {
subMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
+import { generateUUID } from "@/utils/id";
function isNearlySameTime({
leftTime,
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/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 e64de11f..00000000
--- a/apps/web/src/animation/timeline-snap-points.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { getElementKeyframes } from "@/animation";
-import type { SceneTracks } from "@/timeline";
-import type { SnapPoint } from "@/timeline/snapping";
-import { addMediaTime } from "@/wasm";
-
-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: addMediaTime({ a: element.startTime, b: 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 e02c8ba8..cfecfada 100644
--- a/apps/web/src/animation/types.ts
+++ b/apps/web/src/animation/types.ts
@@ -51,7 +51,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,
+ });
+}