summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/animation
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib/animation')
-rw-r--r--apps/web/src/lib/animation/color-channel.ts38
-rw-r--r--apps/web/src/lib/animation/graphic-param-channel.ts154
-rw-r--r--apps/web/src/lib/animation/index.ts142
-rw-r--r--apps/web/src/lib/animation/keyframe-query.ts144
-rw-r--r--apps/web/src/lib/animation/number-channel.ts40
-rw-r--r--apps/web/src/lib/animation/property-groups.ts78
-rw-r--r--apps/web/src/lib/animation/property-registry.ts738
-rw-r--r--apps/web/src/lib/animation/resolve.ts270
-rw-r--r--apps/web/src/lib/animation/target-resolver.ts554
-rw-r--r--apps/web/src/lib/animation/types.ts238
-rw-r--r--apps/web/src/lib/animation/vector-channel.ts144
11 files changed, 1270 insertions, 1270 deletions
diff --git a/apps/web/src/lib/animation/color-channel.ts b/apps/web/src/lib/animation/color-channel.ts
index a34b469b..c1c05f9a 100644
--- a/apps/web/src/lib/animation/color-channel.ts
+++ b/apps/web/src/lib/animation/color-channel.ts
@@ -1,19 +1,19 @@
-import type {
- AnimationPropertyPath,
- ColorAnimationChannel,
- ElementAnimations,
-} from "@/lib/animation/types";
-
-export function getColorChannelForPath({
- animations,
- propertyPath,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
-}): ColorAnimationChannel | undefined {
- const channel = animations?.channels[propertyPath];
- if (!channel || channel.valueKind !== "color") {
- return undefined;
- }
- return channel;
-}
+import type {
+ AnimationPropertyPath,
+ ColorAnimationChannel,
+ ElementAnimations,
+} from "@/lib/animation/types";
+
+export function getColorChannelForPath({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+}): ColorAnimationChannel | undefined {
+ const channel = animations?.channels[propertyPath];
+ if (!channel || channel.valueKind !== "color") {
+ return undefined;
+ }
+ return channel;
+}
diff --git a/apps/web/src/lib/animation/graphic-param-channel.ts b/apps/web/src/lib/animation/graphic-param-channel.ts
index 7db544e3..22f78ba5 100644
--- a/apps/web/src/lib/animation/graphic-param-channel.ts
+++ b/apps/web/src/lib/animation/graphic-param-channel.ts
@@ -1,77 +1,77 @@
-import type {
- ElementAnimations,
- GraphicParamPath,
-} from "@/lib/animation/types";
-import type { ParamValues } from "@/lib/params";
-import {
- getGraphicDefinition,
- resolveGraphicParams,
-} from "@/lib/graphics";
-import { getChannel } from "./keyframes";
-import { getChannelValueAtTime } from "./interpolation";
-
-export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
-
-export function buildGraphicParamPath({
- paramKey,
-}: {
- paramKey: string;
-}): GraphicParamPath {
- return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
-}
-
-export function isGraphicParamPath(
- propertyPath: string,
-): propertyPath is GraphicParamPath {
- return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
-}
-
-export function parseGraphicParamPath({
- propertyPath,
-}: {
- propertyPath: string;
-}): { paramKey: string } | null {
- if (!isGraphicParamPath(propertyPath)) {
- return null;
- }
-
- const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
- return paramKey.length > 0 ? { paramKey } : null;
-}
-
-export function resolveGraphicParamsAtTime({
- element,
- localTime,
-}: {
- element: {
- definitionId: string;
- params: ParamValues;
- animations?: ElementAnimations;
- };
- localTime: number;
-}): ParamValues {
- const definition = getGraphicDefinition({
- definitionId: element.definitionId,
- });
- const baseParams = resolveGraphicParams(definition, element.params);
- const resolved: ParamValues = { ...baseParams };
-
- for (const param of definition.params) {
- const path = buildGraphicParamPath({ paramKey: param.key });
- const channel = getChannel({
- animations: element.animations,
- propertyPath: path,
- });
- if (!channel || channel.keyframes.length === 0) {
- continue;
- }
-
- resolved[param.key] = getChannelValueAtTime({
- channel,
- time: Math.max(0, localTime),
- fallbackValue: baseParams[param.key] ?? param.default,
- }) as number | string | boolean;
- }
-
- return resolved;
-}
+import type {
+ ElementAnimations,
+ GraphicParamPath,
+} from "@/lib/animation/types";
+import type { ParamValues } from "@/lib/params";
+import {
+ getGraphicDefinition,
+ resolveGraphicParams,
+} from "@/lib/graphics";
+import { getChannel } from "./keyframes";
+import { getChannelValueAtTime } from "./interpolation";
+
+export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
+
+export function buildGraphicParamPath({
+ paramKey,
+}: {
+ paramKey: string;
+}): GraphicParamPath {
+ return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
+}
+
+export function isGraphicParamPath(
+ propertyPath: string,
+): propertyPath is GraphicParamPath {
+ return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
+}
+
+export function parseGraphicParamPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): { paramKey: string } | null {
+ if (!isGraphicParamPath(propertyPath)) {
+ return null;
+ }
+
+ const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
+ return paramKey.length > 0 ? { paramKey } : null;
+}
+
+export function resolveGraphicParamsAtTime({
+ element,
+ localTime,
+}: {
+ element: {
+ definitionId: string;
+ params: ParamValues;
+ animations?: ElementAnimations;
+ };
+ localTime: number;
+}): ParamValues {
+ const definition = getGraphicDefinition({
+ definitionId: element.definitionId,
+ });
+ const baseParams = resolveGraphicParams(definition, element.params);
+ const resolved: ParamValues = { ...baseParams };
+
+ for (const param of definition.params) {
+ const path = buildGraphicParamPath({ paramKey: param.key });
+ const channel = getChannel({
+ animations: element.animations,
+ propertyPath: path,
+ });
+ if (!channel || channel.keyframes.length === 0) {
+ continue;
+ }
+
+ resolved[param.key] = getChannelValueAtTime({
+ channel,
+ time: Math.max(0, localTime),
+ fallbackValue: baseParams[param.key] ?? param.default,
+ }) as number | string | boolean;
+ }
+
+ return resolved;
+}
diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts
index 3dcddad3..819a59e5 100644
--- a/apps/web/src/lib/animation/index.ts
+++ b/apps/web/src/lib/animation/index.ts
@@ -1,71 +1,71 @@
-export {
- getChannelValueAtTime,
- getNumberChannelValueAtTime,
- getVectorChannelValueAtTime,
- normalizeChannel,
-} from "./interpolation";
-
-export {
- clampAnimationsToDuration,
- cloneAnimations,
- getChannel,
- removeElementKeyframe,
- retimeElementKeyframe,
- setChannel,
- splitAnimationsAtTime,
- upsertElementKeyframe,
- upsertPathKeyframe,
-} from "./keyframes";
-
-export {
- getElementLocalTime,
- resolveColorAtTime,
- resolveNumberAtTime,
- resolveOpacityAtTime,
- resolveTransformAtTime,
-} from "./resolve";
-
-export {
- coerceAnimationValueForProperty,
- getAnimationPropertyDefinition,
- getDefaultInterpolationForProperty,
- getElementBaseValueForProperty,
- isAnimationPropertyPath,
- supportsAnimationProperty,
- type AnimationPropertyDefinition,
- type NumericSpec,
- type NumericRange,
- withElementBaseValueForProperty,
-} from "./property-registry";
-
-export {
- getElementKeyframes,
- getKeyframeAtTime,
- hasKeyframesForPath,
-} from "./keyframe-query";
-
-export {
- buildGraphicParamPath,
- isGraphicParamPath,
- parseGraphicParamPath,
- resolveGraphicParamsAtTime,
-} from "./graphic-param-channel";
-
-export {
- isAnimationPath,
- resolveAnimationTarget,
- getParamValueKind,
- getParamDefaultInterpolation,
- type AnimationPathDescriptor,
-} from "./target-resolver";
-
-export {
- getGroupKeyframesAtTime,
- hasGroupKeyframeAtTime,
- type GroupKeyframeRef,
-} from "./property-groups";
-
-export {
- getVectorChannelForPath,
- isVectorValue,
-} from "./vector-channel";
+export {
+ getChannelValueAtTime,
+ getNumberChannelValueAtTime,
+ getVectorChannelValueAtTime,
+ normalizeChannel,
+} from "./interpolation";
+
+export {
+ clampAnimationsToDuration,
+ cloneAnimations,
+ getChannel,
+ removeElementKeyframe,
+ retimeElementKeyframe,
+ setChannel,
+ splitAnimationsAtTime,
+ upsertElementKeyframe,
+ upsertPathKeyframe,
+} from "./keyframes";
+
+export {
+ getElementLocalTime,
+ resolveColorAtTime,
+ resolveNumberAtTime,
+ resolveOpacityAtTime,
+ resolveTransformAtTime,
+} from "./resolve";
+
+export {
+ coerceAnimationValueForProperty,
+ getAnimationPropertyDefinition,
+ getDefaultInterpolationForProperty,
+ getElementBaseValueForProperty,
+ isAnimationPropertyPath,
+ supportsAnimationProperty,
+ type AnimationPropertyDefinition,
+ type NumericSpec,
+ type NumericRange,
+ withElementBaseValueForProperty,
+} from "./property-registry";
+
+export {
+ getElementKeyframes,
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+} from "./keyframe-query";
+
+export {
+ buildGraphicParamPath,
+ isGraphicParamPath,
+ parseGraphicParamPath,
+ resolveGraphicParamsAtTime,
+} from "./graphic-param-channel";
+
+export {
+ isAnimationPath,
+ resolveAnimationTarget,
+ getParamValueKind,
+ getParamDefaultInterpolation,
+ type AnimationPathDescriptor,
+} from "./target-resolver";
+
+export {
+ getGroupKeyframesAtTime,
+ hasGroupKeyframeAtTime,
+ type GroupKeyframeRef,
+} from "./property-groups";
+
+export {
+ getVectorChannelForPath,
+ isVectorValue,
+} from "./vector-channel";
diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts
index 637b0289..62c8a171 100644
--- a/apps/web/src/lib/animation/keyframe-query.ts
+++ b/apps/web/src/lib/animation/keyframe-query.ts
@@ -1,72 +1,72 @@
-import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
-import type {
- AnimationPath,
- ElementAnimations,
- ElementKeyframe,
-} from "@/lib/animation/types";
-import { isAnimationPath } from "./target-resolver";
-
-export function getElementKeyframes({
- animations,
-}: {
- animations: ElementAnimations | undefined;
-}): ElementKeyframe[] {
- if (!animations) {
- return [];
- }
-
- return Object.entries(animations.channels).flatMap(
- ([propertyPath, channel]) => {
- if (
- !channel ||
- channel.keyframes.length === 0 ||
- !isAnimationPath(propertyPath)
- ) {
- return [];
- }
-
- return channel.keyframes.map((keyframe) => ({
- propertyPath,
- id: keyframe.id,
- time: keyframe.time,
- value: keyframe.value,
- interpolation: keyframe.interpolation,
- }));
- },
- );
-}
-
-export function hasKeyframesForPath({
- animations,
- propertyPath,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
-}): boolean {
- const channel = animations?.channels[propertyPath];
- return Boolean(channel && channel.keyframes.length > 0);
-}
-
-export function getKeyframeAtTime({
- animations,
- propertyPath,
- time,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
- time: number;
-}): ElementKeyframe | null {
- const channel = animations?.channels[propertyPath];
- if (!channel || channel.keyframes.length === 0) return null;
- const keyframe = channel.keyframes.find(
- (keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
- );
- if (!keyframe) return null;
- return {
- propertyPath,
- id: keyframe.id,
- time: keyframe.time,
- value: keyframe.value,
- interpolation: keyframe.interpolation,
- };
-}
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import type {
+ AnimationPath,
+ ElementAnimations,
+ ElementKeyframe,
+} from "@/lib/animation/types";
+import { isAnimationPath } from "./target-resolver";
+
+export function getElementKeyframes({
+ animations,
+}: {
+ animations: ElementAnimations | undefined;
+}): ElementKeyframe[] {
+ if (!animations) {
+ return [];
+ }
+
+ return Object.entries(animations.channels).flatMap(
+ ([propertyPath, channel]) => {
+ if (
+ !channel ||
+ channel.keyframes.length === 0 ||
+ !isAnimationPath(propertyPath)
+ ) {
+ return [];
+ }
+
+ return channel.keyframes.map((keyframe) => ({
+ propertyPath,
+ id: keyframe.id,
+ time: keyframe.time,
+ value: keyframe.value,
+ interpolation: keyframe.interpolation,
+ }));
+ },
+ );
+}
+
+export function hasKeyframesForPath({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+}): boolean {
+ const channel = animations?.channels[propertyPath];
+ return Boolean(channel && channel.keyframes.length > 0);
+}
+
+export function getKeyframeAtTime({
+ animations,
+ propertyPath,
+ time,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ time: number;
+}): ElementKeyframe | null {
+ const channel = animations?.channels[propertyPath];
+ if (!channel || channel.keyframes.length === 0) return null;
+ const keyframe = channel.keyframes.find(
+ (keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
+ );
+ if (!keyframe) return null;
+ return {
+ propertyPath,
+ id: keyframe.id,
+ time: keyframe.time,
+ value: keyframe.value,
+ interpolation: keyframe.interpolation,
+ };
+}
diff --git a/apps/web/src/lib/animation/number-channel.ts b/apps/web/src/lib/animation/number-channel.ts
index 859da863..3e193131 100644
--- a/apps/web/src/lib/animation/number-channel.ts
+++ b/apps/web/src/lib/animation/number-channel.ts
@@ -1,20 +1,20 @@
-import type {
- AnimationPropertyPath,
- ElementAnimations,
- NumberAnimationChannel,
-} from "@/lib/animation/types";
-
-export function getNumberChannelForPath({
- animations,
- propertyPath,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
-}): NumberAnimationChannel | undefined {
- const channel = animations?.channels[propertyPath];
- if (!channel || channel.valueKind !== "number") {
- return undefined;
- }
-
- return channel;
-}
+import type {
+ AnimationPropertyPath,
+ ElementAnimations,
+ NumberAnimationChannel,
+} from "@/lib/animation/types";
+
+export function getNumberChannelForPath({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+}): NumberAnimationChannel | undefined {
+ const channel = animations?.channels[propertyPath];
+ if (!channel || channel.valueKind !== "number") {
+ return undefined;
+ }
+
+ return channel;
+}
diff --git a/apps/web/src/lib/animation/property-groups.ts b/apps/web/src/lib/animation/property-groups.ts
index 61b4fc4a..1e475dac 100644
--- a/apps/web/src/lib/animation/property-groups.ts
+++ b/apps/web/src/lib/animation/property-groups.ts
@@ -1,39 +1,39 @@
-import type {
- AnimationPropertyGroup,
- AnimationPropertyPath,
- ElementAnimations,
-} from "@/lib/animation/types";
-import { ANIMATION_PROPERTY_GROUPS } from "@/lib/animation/types";
-import { getKeyframeAtTime } from "./keyframe-query";
-
-export interface GroupKeyframeRef {
- propertyPath: AnimationPropertyPath;
- keyframeId: string;
-}
-
-export function getGroupKeyframesAtTime({
- animations,
- group,
- time,
-}: {
- animations: ElementAnimations | undefined;
- group: AnimationPropertyGroup;
- time: number;
-}): GroupKeyframeRef[] {
- return ANIMATION_PROPERTY_GROUPS[group].flatMap((propertyPath) => {
- const keyframe = getKeyframeAtTime({ animations, propertyPath, time });
- return keyframe ? [{ propertyPath, keyframeId: keyframe.id }] : [];
- });
-}
-
-export function hasGroupKeyframeAtTime({
- animations,
- group,
- time,
-}: {
- animations: ElementAnimations | undefined;
- group: AnimationPropertyGroup;
- time: number;
-}): boolean {
- return getGroupKeyframesAtTime({ animations, group, time }).length > 0;
-}
+import type {
+ AnimationPropertyGroup,
+ AnimationPropertyPath,
+ ElementAnimations,
+} from "@/lib/animation/types";
+import { ANIMATION_PROPERTY_GROUPS } from "@/lib/animation/types";
+import { getKeyframeAtTime } from "./keyframe-query";
+
+export interface GroupKeyframeRef {
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+}
+
+export function getGroupKeyframesAtTime({
+ animations,
+ group,
+ time,
+}: {
+ animations: ElementAnimations | undefined;
+ group: AnimationPropertyGroup;
+ time: number;
+}): GroupKeyframeRef[] {
+ return ANIMATION_PROPERTY_GROUPS[group].flatMap((propertyPath) => {
+ const keyframe = getKeyframeAtTime({ animations, propertyPath, time });
+ return keyframe ? [{ propertyPath, keyframeId: keyframe.id }] : [];
+ });
+}
+
+export function hasGroupKeyframeAtTime({
+ animations,
+ group,
+ time,
+}: {
+ animations: ElementAnimations | undefined;
+ group: AnimationPropertyGroup;
+ time: number;
+}): boolean {
+ return getGroupKeyframesAtTime({ animations, group, time }).length > 0;
+}
diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts
index 46990754..6d20e324 100644
--- a/apps/web/src/lib/animation/property-registry.ts
+++ b/apps/web/src/lib/animation/property-registry.ts
@@ -1,369 +1,369 @@
-import type {
- AnimationInterpolation,
- AnimationPropertyPath,
- AnimationValue,
- AnimationValueKind,
- DiscreteValue,
- VectorValue,
-} from "@/lib/animation/types";
-import { isVectorValue } from "./vector-channel";
-import type { TimelineElement } from "@/lib/timeline";
-import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
-import {
- CORNER_RADIUS_MAX,
- CORNER_RADIUS_MIN,
-} from "@/constants/text-constants";
-import {
- canElementHaveAudio,
- isVisualElement,
-} from "@/lib/timeline/element-utils";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { snapToStep } from "@/utils/math";
-
-export interface NumericSpec {
- min?: number;
- max?: number;
- step?: number;
-}
-
-export type NumericRange = NumericSpec;
-
-export interface AnimationPropertyDefinition {
- valueKind: AnimationValueKind;
- defaultInterpolation: AnimationInterpolation;
- numericRange?: NumericSpec;
- supportsElement: ({ element }: { element: TimelineElement }) => boolean;
- getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
- setValue: ({
- element,
- value,
- }: {
- element: TimelineElement;
- value: AnimationValue;
- }) => TimelineElement;
-}
-
-const ANIMATION_PROPERTY_REGISTRY: Record<
- AnimationPropertyPath,
- AnimationPropertyDefinition
-> = {
- "transform.position": {
- valueKind: "vector",
- defaultInterpolation: "linear",
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.position : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: {
- ...element.transform,
- position: value as VectorValue,
- },
- }
- : element,
- },
- "transform.scaleX": {
- valueKind: "number",
- defaultInterpolation: "linear",
- 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": {
- valueKind: "number",
- defaultInterpolation: "linear",
- 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": {
- valueKind: "number",
- defaultInterpolation: "linear",
- 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: {
- valueKind: "number",
- defaultInterpolation: "linear",
- 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: {
- valueKind: "number",
- defaultInterpolation: "linear",
- 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: {
- valueKind: "color",
- defaultInterpolation: "linear",
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) => (element.type === "text" ? element.color : null),
- setValue: ({ element, value }) =>
- element.type === "text"
- ? { ...element, color: value as string }
- : element,
- },
- "background.color": {
- valueKind: "color",
- defaultInterpolation: "linear",
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text" ? element.background.color : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, color: value as string },
- }
- : element,
- },
- "background.paddingX": {
- valueKind: "number",
- defaultInterpolation: "linear",
- 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": {
- valueKind: "number",
- defaultInterpolation: "linear",
- 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": {
- valueKind: "number",
- defaultInterpolation: "linear",
- 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": {
- valueKind: "number",
- defaultInterpolation: "linear",
- 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": {
- valueKind: "number",
- defaultInterpolation: "linear",
- 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 coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
- if (coercedValue === null) {
- 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 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));
-}
-
-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 applyNumericSpec({
- value,
- numericRange: propertyDefinition.numericRange,
- });
- }
-
- if (propertyDefinition.valueKind === "color") {
- return typeof value === "string" ? value : null;
- }
-
- if (propertyDefinition.valueKind === "vector") {
- return isVectorValue(value) ? value : null;
- }
-
- if (typeof value === "string" || typeof value === "boolean") {
- return value as DiscreteValue;
- }
-
- return null;
-}
+import type {
+ AnimationInterpolation,
+ AnimationPropertyPath,
+ AnimationValue,
+ AnimationValueKind,
+ DiscreteValue,
+ VectorValue,
+} from "@/lib/animation/types";
+import { isVectorValue } from "./vector-channel";
+import type { TimelineElement } from "@/lib/timeline";
+import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
+import {
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
+} from "@/constants/text-constants";
+import {
+ canElementHaveAudio,
+ isVisualElement,
+} from "@/lib/timeline/element-utils";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { snapToStep } from "@/utils/math";
+
+export interface NumericSpec {
+ min?: number;
+ max?: number;
+ step?: number;
+}
+
+export type NumericRange = NumericSpec;
+
+export interface AnimationPropertyDefinition {
+ valueKind: AnimationValueKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRange?: NumericSpec;
+ supportsElement: ({ element }: { element: TimelineElement }) => boolean;
+ getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
+ setValue: ({
+ element,
+ value,
+ }: {
+ element: TimelineElement;
+ value: AnimationValue;
+ }) => TimelineElement;
+}
+
+const ANIMATION_PROPERTY_REGISTRY: Record<
+ AnimationPropertyPath,
+ AnimationPropertyDefinition
+> = {
+ "transform.position": {
+ valueKind: "vector",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.position : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: {
+ ...element.transform,
+ position: value as VectorValue,
+ },
+ }
+ : element,
+ },
+ "transform.scaleX": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ 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": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ 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": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ 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: {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ 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: {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ 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: {
+ valueKind: "color",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) => (element.type === "text" ? element.color : null),
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? { ...element, color: value as string }
+ : element,
+ },
+ "background.color": {
+ valueKind: "color",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text" ? element.background.color : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, color: value as string },
+ }
+ : element,
+ },
+ "background.paddingX": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ 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": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ 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": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ 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": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ 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": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ 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 coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
+ if (coercedValue === null) {
+ 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 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));
+}
+
+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 applyNumericSpec({
+ value,
+ numericRange: propertyDefinition.numericRange,
+ });
+ }
+
+ if (propertyDefinition.valueKind === "color") {
+ return typeof value === "string" ? value : null;
+ }
+
+ if (propertyDefinition.valueKind === "vector") {
+ return isVectorValue(value) ? value : null;
+ }
+
+ if (typeof value === "string" || typeof value === "boolean") {
+ return value as DiscreteValue;
+ }
+
+ return null;
+}
diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts
index 8709627d..5c356f28 100644
--- a/apps/web/src/lib/animation/resolve.ts
+++ b/apps/web/src/lib/animation/resolve.ts
@@ -1,135 +1,135 @@
-import type {
- AnimationPropertyPath,
- ElementAnimations,
-} from "@/lib/animation/types";
-import type { Transform } from "@/lib/rendering";
-import {
- getColorValueAtTime,
- getNumberChannelValueAtTime,
- getVectorChannelValueAtTime,
-} from "./interpolation";
-import { getColorChannelForPath } from "./color-channel";
-import { getNumberChannelForPath } from "./number-channel";
-import { getVectorChannelForPath } from "./vector-channel";
-
-export function getElementLocalTime({
- timelineTime,
- elementStartTime,
- elementDuration,
-}: {
- timelineTime: number;
- elementStartTime: number;
- elementDuration: number;
-}): number {
- const localTime = timelineTime - elementStartTime;
- if (localTime <= 0) {
- return 0;
- }
-
- if (localTime >= elementDuration) {
- return elementDuration;
- }
-
- return localTime;
-}
-
-export function resolveTransformAtTime({
- baseTransform,
- animations,
- localTime,
-}: {
- baseTransform: Transform;
- animations: ElementAnimations | undefined;
- localTime: number;
-}): Transform {
- const safeLocalTime = Math.max(0, localTime);
- return {
- position: getVectorChannelValueAtTime({
- channel: getVectorChannelForPath({
- animations,
- propertyPath: "transform.position",
- }),
- time: safeLocalTime,
- fallbackValue: baseTransform.position,
- }),
- scaleX: getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
- animations,
- propertyPath: "transform.scaleX",
- }),
- time: safeLocalTime,
- fallbackValue: baseTransform.scaleX,
- }),
- scaleY: getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
- animations,
- propertyPath: "transform.scaleY",
- }),
- time: safeLocalTime,
- fallbackValue: baseTransform.scaleY,
- }),
- rotate: getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
- animations,
- propertyPath: "transform.rotate",
- }),
- time: safeLocalTime,
- fallbackValue: baseTransform.rotate,
- }),
- };
-}
-
-export function resolveOpacityAtTime({
- baseOpacity,
- animations,
- localTime,
-}: {
- baseOpacity: number;
- animations: ElementAnimations | undefined;
- localTime: number;
-}): number {
- return getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
- animations,
- propertyPath: "opacity",
- }),
- time: Math.max(0, localTime),
- fallbackValue: baseOpacity,
- });
-}
-
-export function resolveNumberAtTime({
- baseValue,
- animations,
- propertyPath,
- localTime,
-}: {
- baseValue: number;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- localTime: number;
-}): number {
- return getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({ animations, propertyPath }),
- time: Math.max(0, localTime),
- fallbackValue: baseValue,
- });
-}
-
-export function resolveColorAtTime({
- baseColor,
- animations,
- propertyPath,
- localTime,
-}: {
- baseColor: string;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- localTime: number;
-}): string {
- return getColorValueAtTime({
- channel: getColorChannelForPath({ animations, propertyPath }),
- time: Math.max(0, localTime),
- fallbackValue: baseColor,
- });
-}
+import type {
+ AnimationPropertyPath,
+ ElementAnimations,
+} from "@/lib/animation/types";
+import type { Transform } from "@/lib/rendering";
+import {
+ getColorValueAtTime,
+ getNumberChannelValueAtTime,
+ getVectorChannelValueAtTime,
+} from "./interpolation";
+import { getColorChannelForPath } from "./color-channel";
+import { getNumberChannelForPath } from "./number-channel";
+import { getVectorChannelForPath } from "./vector-channel";
+
+export function getElementLocalTime({
+ timelineTime,
+ elementStartTime,
+ elementDuration,
+}: {
+ timelineTime: number;
+ elementStartTime: number;
+ elementDuration: number;
+}): number {
+ const localTime = timelineTime - elementStartTime;
+ if (localTime <= 0) {
+ return 0;
+ }
+
+ if (localTime >= elementDuration) {
+ return elementDuration;
+ }
+
+ return localTime;
+}
+
+export function resolveTransformAtTime({
+ baseTransform,
+ animations,
+ localTime,
+}: {
+ baseTransform: Transform;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+}): Transform {
+ const safeLocalTime = Math.max(0, localTime);
+ return {
+ position: getVectorChannelValueAtTime({
+ channel: getVectorChannelForPath({
+ animations,
+ propertyPath: "transform.position",
+ }),
+ time: safeLocalTime,
+ fallbackValue: baseTransform.position,
+ }),
+ scaleX: getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({
+ animations,
+ propertyPath: "transform.scaleX",
+ }),
+ time: safeLocalTime,
+ fallbackValue: baseTransform.scaleX,
+ }),
+ scaleY: getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({
+ animations,
+ propertyPath: "transform.scaleY",
+ }),
+ time: safeLocalTime,
+ fallbackValue: baseTransform.scaleY,
+ }),
+ rotate: getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({
+ animations,
+ propertyPath: "transform.rotate",
+ }),
+ time: safeLocalTime,
+ fallbackValue: baseTransform.rotate,
+ }),
+ };
+}
+
+export function resolveOpacityAtTime({
+ baseOpacity,
+ animations,
+ localTime,
+}: {
+ baseOpacity: number;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+}): number {
+ return getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({
+ animations,
+ propertyPath: "opacity",
+ }),
+ time: Math.max(0, localTime),
+ fallbackValue: baseOpacity,
+ });
+}
+
+export function resolveNumberAtTime({
+ baseValue,
+ animations,
+ propertyPath,
+ localTime,
+}: {
+ baseValue: number;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+}): number {
+ return getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({ animations, propertyPath }),
+ time: Math.max(0, localTime),
+ fallbackValue: baseValue,
+ });
+}
+
+export function resolveColorAtTime({
+ baseColor,
+ animations,
+ propertyPath,
+ localTime,
+}: {
+ baseColor: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+}): string {
+ return getColorValueAtTime({
+ channel: getColorChannelForPath({ animations, propertyPath }),
+ time: Math.max(0, localTime),
+ fallbackValue: baseColor,
+ });
+}
diff --git a/apps/web/src/lib/animation/target-resolver.ts b/apps/web/src/lib/animation/target-resolver.ts
index 10b83775..6675d443 100644
--- a/apps/web/src/lib/animation/target-resolver.ts
+++ b/apps/web/src/lib/animation/target-resolver.ts
@@ -1,277 +1,277 @@
-import type {
- AnimationInterpolation,
- AnimationPath,
- AnimationValue,
- AnimationValueKind,
-} from "@/lib/animation/types";
-import {
- parseEffectParamPath,
- isEffectParamPath,
-} from "@/lib/animation/effect-param-channel";
-import {
- isGraphicParamPath,
- parseGraphicParamPath,
-} from "@/lib/animation/graphic-param-channel";
-import type { ParamDefinition } from "@/lib/params";
-import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
-import { getGraphicDefinition } from "@/lib/graphics";
-import type { TimelineElement } from "@/lib/timeline";
-import { isVisualElement } from "@/lib/timeline/element-utils";
-import { snapToStep } from "@/utils/math";
-import {
- coerceAnimationValueForProperty,
- getAnimationPropertyDefinition,
- getElementBaseValueForProperty,
- isAnimationPropertyPath,
- type NumericSpec,
- withElementBaseValueForProperty,
-} from "./property-registry";
-
-export interface AnimationPathDescriptor {
- valueKind: AnimationValueKind;
- defaultInterpolation: AnimationInterpolation;
- numericRange?: NumericSpec;
- getBaseValue(): AnimationValue | null;
- setBaseValue(value: AnimationValue): TimelineElement;
-}
-
-export function getParamValueKind({
- param,
-}: {
- param: ParamDefinition;
-}): AnimationValueKind {
- 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;
-}): NumericSpec | undefined {
- if (param.type !== "number") {
- return undefined;
- }
-
- return {
- min: param.min,
- max: param.max,
- step: param.step,
- };
-}
-
-function coerceParamValue({
- 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 {
- valueKind: getParamValueKind({ param }),
- defaultInterpolation: getParamDefaultInterpolation({ param }),
- numericRange: getParamNumericRange({ param }),
- getBaseValue: () => element.params[param.key] ?? param.default,
- setBaseValue: (value) => {
- const coercedValue = coerceParamValue({ 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 {
- valueKind: getParamValueKind({ param }),
- defaultInterpolation: getParamDefaultInterpolation({ param }),
- numericRange: getParamNumericRange({ param }),
- getBaseValue: () => effect.params[param.key] ?? param.default,
- setBaseValue: (value) => {
- const coercedValue = coerceParamValue({ 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 {
- valueKind: propertyDefinition.valueKind,
- defaultInterpolation: propertyDefinition.defaultInterpolation,
- numericRange: propertyDefinition.numericRange,
- getBaseValue: () =>
- getElementBaseValueForProperty({
- element,
- propertyPath: path,
- }),
- setBaseValue: (value) => {
- const coercedValue = coerceAnimationValueForProperty({
- propertyPath: path,
- 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;
-}
+import type {
+ AnimationInterpolation,
+ AnimationPath,
+ AnimationValue,
+ AnimationValueKind,
+} from "@/lib/animation/types";
+import {
+ parseEffectParamPath,
+ isEffectParamPath,
+} from "@/lib/animation/effect-param-channel";
+import {
+ isGraphicParamPath,
+ parseGraphicParamPath,
+} from "@/lib/animation/graphic-param-channel";
+import type { ParamDefinition } from "@/lib/params";
+import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
+import { getGraphicDefinition } from "@/lib/graphics";
+import type { TimelineElement } from "@/lib/timeline";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import { snapToStep } from "@/utils/math";
+import {
+ coerceAnimationValueForProperty,
+ getAnimationPropertyDefinition,
+ getElementBaseValueForProperty,
+ isAnimationPropertyPath,
+ type NumericSpec,
+ withElementBaseValueForProperty,
+} from "./property-registry";
+
+export interface AnimationPathDescriptor {
+ valueKind: AnimationValueKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRange?: NumericSpec;
+ getBaseValue(): AnimationValue | null;
+ setBaseValue(value: AnimationValue): TimelineElement;
+}
+
+export function getParamValueKind({
+ param,
+}: {
+ param: ParamDefinition;
+}): AnimationValueKind {
+ 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;
+}): NumericSpec | undefined {
+ if (param.type !== "number") {
+ return undefined;
+ }
+
+ return {
+ min: param.min,
+ max: param.max,
+ step: param.step,
+ };
+}
+
+function coerceParamValue({
+ 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 {
+ valueKind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({ param }),
+ numericRange: getParamNumericRange({ param }),
+ getBaseValue: () => element.params[param.key] ?? param.default,
+ setBaseValue: (value) => {
+ const coercedValue = coerceParamValue({ 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 {
+ valueKind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({ param }),
+ numericRange: getParamNumericRange({ param }),
+ getBaseValue: () => effect.params[param.key] ?? param.default,
+ setBaseValue: (value) => {
+ const coercedValue = coerceParamValue({ 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 {
+ valueKind: propertyDefinition.valueKind,
+ defaultInterpolation: propertyDefinition.defaultInterpolation,
+ numericRange: propertyDefinition.numericRange,
+ getBaseValue: () =>
+ getElementBaseValueForProperty({
+ element,
+ propertyPath: path,
+ }),
+ setBaseValue: (value) => {
+ const coercedValue = coerceAnimationValueForProperty({
+ propertyPath: path,
+ 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/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts
index 913911d0..ac2e0e78 100644
--- a/apps/web/src/lib/animation/types.ts
+++ b/apps/web/src/lib/animation/types.ts
@@ -1,119 +1,119 @@
-export const ANIMATION_PROPERTY_PATHS = [
- "transform.position",
- "transform.scaleX",
- "transform.scaleY",
- "transform.rotate",
- "opacity",
- "volume",
- "color",
- "background.color",
- "background.paddingX",
- "background.paddingY",
- "background.offsetX",
- "background.offsetY",
- "background.cornerRadius",
-] as const;
-
-export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
-export type GraphicParamPath = `params.${string}`;
-export type EffectParamPath = `effects.${string}.params.${string}`;
-export type AnimationPath =
- | AnimationPropertyPath
- | GraphicParamPath
- | EffectParamPath;
-
-export const ANIMATION_PROPERTY_GROUPS = {
- "transform.scale": ["transform.scaleX", "transform.scaleY"],
-} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
-
-export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
-
-export type VectorValue = { x: number; y: number };
-
-export type AnimationValueKind = "number" | "color" | "discrete" | "vector";
-export type DiscreteValue = boolean | string;
-export type AnimationValue = number | string | boolean | VectorValue;
-
-export type ContinuousKeyframeInterpolation = "linear" | "hold";
-export type DiscreteKeyframeInterpolation = "hold";
-export type AnimationInterpolation =
- | ContinuousKeyframeInterpolation
- | DiscreteKeyframeInterpolation;
-
-interface BaseAnimationKeyframe<
- TValue extends AnimationValue,
- TInterpolation extends AnimationInterpolation,
-> {
- id: string;
- time: number; // relative to element start time
- value: TValue;
- interpolation: TInterpolation;
-}
-
-export interface NumberKeyframe
- extends BaseAnimationKeyframe<number, ContinuousKeyframeInterpolation> {}
-
-export interface ColorKeyframe
- extends BaseAnimationKeyframe<string, ContinuousKeyframeInterpolation> {}
-
-export interface DiscreteKeyframe
- extends BaseAnimationKeyframe<DiscreteValue, DiscreteKeyframeInterpolation> {}
-
-export interface VectorKeyframe
- extends BaseAnimationKeyframe<VectorValue, ContinuousKeyframeInterpolation> {}
-
-export type AnimationKeyframe =
- | NumberKeyframe
- | ColorKeyframe
- | DiscreteKeyframe
- | VectorKeyframe;
-
-interface BaseAnimationChannel<
- TValueKind extends AnimationValueKind,
- TKeyframe extends AnimationKeyframe,
-> {
- valueKind: TValueKind;
- keyframes: TKeyframe[];
-}
-
-export interface NumberAnimationChannel
- extends BaseAnimationChannel<"number", NumberKeyframe> {}
-
-export interface ColorAnimationChannel
- extends BaseAnimationChannel<"color", ColorKeyframe> {}
-
-export interface DiscreteAnimationChannel
- extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {}
-
-export interface VectorAnimationChannel
- extends BaseAnimationChannel<"vector", VectorKeyframe> {}
-
-export type AnimationChannel =
- | NumberAnimationChannel
- | ColorAnimationChannel
- | DiscreteAnimationChannel
- | VectorAnimationChannel;
-
-export type ElementAnimationChannelMap = Record<
- string,
- AnimationChannel | undefined
->;
-
-export interface ElementAnimations {
- channels: ElementAnimationChannelMap;
-}
-
-export interface ElementKeyframe {
- propertyPath: AnimationPath;
- id: string;
- time: number;
- value: AnimationValue;
- interpolation: AnimationInterpolation;
-}
-
-export interface SelectedKeyframeRef {
- trackId: string;
- elementId: string;
- propertyPath: AnimationPath;
- keyframeId: string;
-}
+export const ANIMATION_PROPERTY_PATHS = [
+ "transform.position",
+ "transform.scaleX",
+ "transform.scaleY",
+ "transform.rotate",
+ "opacity",
+ "volume",
+ "color",
+ "background.color",
+ "background.paddingX",
+ "background.paddingY",
+ "background.offsetX",
+ "background.offsetY",
+ "background.cornerRadius",
+] as const;
+
+export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
+export type GraphicParamPath = `params.${string}`;
+export type EffectParamPath = `effects.${string}.params.${string}`;
+export type AnimationPath =
+ | AnimationPropertyPath
+ | GraphicParamPath
+ | EffectParamPath;
+
+export const ANIMATION_PROPERTY_GROUPS = {
+ "transform.scale": ["transform.scaleX", "transform.scaleY"],
+} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
+
+export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
+
+export type VectorValue = { x: number; y: number };
+
+export type AnimationValueKind = "number" | "color" | "discrete" | "vector";
+export type DiscreteValue = boolean | string;
+export type AnimationValue = number | string | boolean | VectorValue;
+
+export type ContinuousKeyframeInterpolation = "linear" | "hold";
+export type DiscreteKeyframeInterpolation = "hold";
+export type AnimationInterpolation =
+ | ContinuousKeyframeInterpolation
+ | DiscreteKeyframeInterpolation;
+
+interface BaseAnimationKeyframe<
+ TValue extends AnimationValue,
+ TInterpolation extends AnimationInterpolation,
+> {
+ id: string;
+ time: number; // relative to element start time
+ value: TValue;
+ interpolation: TInterpolation;
+}
+
+export interface NumberKeyframe
+ extends BaseAnimationKeyframe<number, ContinuousKeyframeInterpolation> {}
+
+export interface ColorKeyframe
+ extends BaseAnimationKeyframe<string, ContinuousKeyframeInterpolation> {}
+
+export interface DiscreteKeyframe
+ extends BaseAnimationKeyframe<DiscreteValue, DiscreteKeyframeInterpolation> {}
+
+export interface VectorKeyframe
+ extends BaseAnimationKeyframe<VectorValue, ContinuousKeyframeInterpolation> {}
+
+export type AnimationKeyframe =
+ | NumberKeyframe
+ | ColorKeyframe
+ | DiscreteKeyframe
+ | VectorKeyframe;
+
+interface BaseAnimationChannel<
+ TValueKind extends AnimationValueKind,
+ TKeyframe extends AnimationKeyframe,
+> {
+ valueKind: TValueKind;
+ keyframes: TKeyframe[];
+}
+
+export interface NumberAnimationChannel
+ extends BaseAnimationChannel<"number", NumberKeyframe> {}
+
+export interface ColorAnimationChannel
+ extends BaseAnimationChannel<"color", ColorKeyframe> {}
+
+export interface DiscreteAnimationChannel
+ extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {}
+
+export interface VectorAnimationChannel
+ extends BaseAnimationChannel<"vector", VectorKeyframe> {}
+
+export type AnimationChannel =
+ | NumberAnimationChannel
+ | ColorAnimationChannel
+ | DiscreteAnimationChannel
+ | VectorAnimationChannel;
+
+export type ElementAnimationChannelMap = Record<
+ string,
+ AnimationChannel | undefined
+>;
+
+export interface ElementAnimations {
+ channels: ElementAnimationChannelMap;
+}
+
+export interface ElementKeyframe {
+ propertyPath: AnimationPath;
+ id: string;
+ time: number;
+ value: AnimationValue;
+ interpolation: AnimationInterpolation;
+}
+
+export interface SelectedKeyframeRef {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+}
diff --git a/apps/web/src/lib/animation/vector-channel.ts b/apps/web/src/lib/animation/vector-channel.ts
index 50c62e40..f7f65049 100644
--- a/apps/web/src/lib/animation/vector-channel.ts
+++ b/apps/web/src/lib/animation/vector-channel.ts
@@ -1,72 +1,72 @@
-import type {
- AnimationPropertyPath,
- ElementAnimations,
- VectorAnimationChannel,
- VectorValue,
-} from "@/lib/animation/types";
-
-export function isVectorValue(value: unknown): value is VectorValue {
- return (
- typeof value === "object" &&
- value !== null &&
- "x" in value &&
- "y" in value &&
- typeof (value as VectorValue).x === "number" &&
- typeof (value as VectorValue).y === "number"
- );
-}
-
-export function getVectorChannelForPath({
- animations,
- propertyPath,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
-}): VectorAnimationChannel | undefined {
- const channel = animations?.channels[propertyPath];
- if (!channel || channel.valueKind !== "vector") {
- return undefined;
- }
- return channel;
-}
-
-export function getVectorChannelValueAtTime({
- channel,
- time,
- fallbackValue,
-}: {
- channel: VectorAnimationChannel | undefined;
- time: number;
- fallbackValue: VectorValue;
-}): VectorValue {
- if (!channel || channel.keyframes.length === 0) {
- return fallbackValue;
- }
-
- const keyframes = [...channel.keyframes].sort((a, b) => a.time - b.time);
- const first = keyframes[0];
- const last = keyframes[keyframes.length - 1];
-
- if (!first || !last) return fallbackValue;
- if (time <= first.time) return first.value;
- if (time >= last.time) return last.value;
-
- for (let i = 0; i < keyframes.length - 1; i++) {
- const left = keyframes[i];
- const right = keyframes[i + 1];
- if (time < left.time || time > right.time) continue;
-
- if (left.interpolation === "hold") return left.value;
-
- const span = right.time - left.time;
- if (span === 0) return right.value;
-
- const t = (time - left.time) / span;
- return {
- x: left.value.x + (right.value.x - left.value.x) * t,
- y: left.value.y + (right.value.y - left.value.y) * t,
- };
- }
-
- return last.value;
-}
+import type {
+ AnimationPropertyPath,
+ ElementAnimations,
+ VectorAnimationChannel,
+ VectorValue,
+} from "@/lib/animation/types";
+
+export function isVectorValue(value: unknown): value is VectorValue {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ "x" in value &&
+ "y" in value &&
+ typeof (value as VectorValue).x === "number" &&
+ typeof (value as VectorValue).y === "number"
+ );
+}
+
+export function getVectorChannelForPath({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+}): VectorAnimationChannel | undefined {
+ const channel = animations?.channels[propertyPath];
+ if (!channel || channel.valueKind !== "vector") {
+ return undefined;
+ }
+ return channel;
+}
+
+export function getVectorChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: VectorAnimationChannel | undefined;
+ time: number;
+ fallbackValue: VectorValue;
+}): VectorValue {
+ if (!channel || channel.keyframes.length === 0) {
+ return fallbackValue;
+ }
+
+ const keyframes = [...channel.keyframes].sort((a, b) => a.time - b.time);
+ const first = keyframes[0];
+ const last = keyframes[keyframes.length - 1];
+
+ if (!first || !last) return fallbackValue;
+ if (time <= first.time) return first.value;
+ if (time >= last.time) return last.value;
+
+ for (let i = 0; i < keyframes.length - 1; i++) {
+ const left = keyframes[i];
+ const right = keyframes[i + 1];
+ if (time < left.time || time > right.time) continue;
+
+ if (left.interpolation === "hold") return left.value;
+
+ const span = right.time - left.time;
+ if (span === 0) return right.value;
+
+ const t = (time - left.time) / span;
+ return {
+ x: left.value.x + (right.value.x - left.value.x) * t,
+ y: left.value.y + (right.value.y - left.value.y) * t,
+ };
+ }
+
+ return last.value;
+}