From 4d127a038bf768eded68aae3cfa5c1b12241bdac Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Mar 2026 00:55:15 +0100 Subject: chore: normalize line endings --- apps/web/src/lib/animation/color-channel.ts | 38 +- .../web/src/lib/animation/graphic-param-channel.ts | 154 ++--- apps/web/src/lib/animation/index.ts | 142 ++-- apps/web/src/lib/animation/keyframe-query.ts | 144 ++-- apps/web/src/lib/animation/number-channel.ts | 40 +- apps/web/src/lib/animation/property-groups.ts | 78 +-- apps/web/src/lib/animation/property-registry.ts | 738 ++++++++++----------- apps/web/src/lib/animation/resolve.ts | 270 ++++---- apps/web/src/lib/animation/target-resolver.ts | 554 ++++++++-------- apps/web/src/lib/animation/types.ts | 238 +++---- apps/web/src/lib/animation/vector-channel.ts | 144 ++-- 11 files changed, 1270 insertions(+), 1270 deletions(-) (limited to 'apps/web/src/lib/animation') 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>; - -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 {} - -export interface ColorKeyframe - extends BaseAnimationKeyframe {} - -export interface DiscreteKeyframe - extends BaseAnimationKeyframe {} - -export interface VectorKeyframe - extends BaseAnimationKeyframe {} - -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>; + +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 {} + +export interface ColorKeyframe + extends BaseAnimationKeyframe {} + +export interface DiscreteKeyframe + extends BaseAnimationKeyframe {} + +export interface VectorKeyframe + extends BaseAnimationKeyframe {} + +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; +} -- cgit v1.3.1