summaryrefslogtreecommitdiff
path: root/apps/web/src/animation/target-resolver.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/animation/target-resolver.ts')
-rw-r--r--apps/web/src/animation/target-resolver.ts285
1 files changed, 285 insertions, 0 deletions
diff --git a/apps/web/src/animation/target-resolver.ts b/apps/web/src/animation/target-resolver.ts
new file mode 100644
index 00000000..32e7917c
--- /dev/null
+++ b/apps/web/src/animation/target-resolver.ts
@@ -0,0 +1,285 @@
+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;
+}