diff options
Diffstat (limited to 'apps/web/src/lib/animation')
| -rw-r--r-- | apps/web/src/lib/animation/__tests__/binding-values.test.ts | 26 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/bezier.ts | 90 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/binding-values.ts | 344 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/curve-bridge.ts | 95 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/effect-param-channel.ts | 100 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/graph-channels.ts | 99 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/graphic-param-channel.ts | 73 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/index.ts | 96 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/interpolation.ts | 375 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/keyframe-query.ts | 294 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/keyframes.ts | 1338 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/property-groups.ts | 39 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/property-registry.ts | 390 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/resolve.ts | 184 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/target-resolver.ts | 285 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/transform.ts | 1 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/types.ts | 222 |
17 files changed, 0 insertions, 4051 deletions
diff --git a/apps/web/src/lib/animation/__tests__/binding-values.test.ts b/apps/web/src/lib/animation/__tests__/binding-values.test.ts deleted file mode 100644 index c5a354e4..00000000 --- a/apps/web/src/lib/animation/__tests__/binding-values.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - composeAnimationValue, - createAnimationBinding, -} from "@/lib/animation/binding-values"; - -describe("binding values", () => { - test("formats composed animated colors as hex", () => { - const binding = createAnimationBinding({ - path: "color", - kind: "color", - }); - - expect( - composeAnimationValue({ - binding, - componentValues: { - r: 1, - g: 0, - b: 0, - a: 1, - }, - }), - ).toBe("#ff0000"); - }); -}); diff --git a/apps/web/src/lib/animation/bezier.ts b/apps/web/src/lib/animation/bezier.ts deleted file mode 100644 index 5a9758ed..00000000 --- a/apps/web/src/lib/animation/bezier.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { ScalarAnimationKey } from "@/lib/animation/types"; - -const BEZIER_SOLVE_ITERATIONS = 20; - -export function getBezierPoint({ - progress, - p0, - p1, - p2, - p3, -}: { - progress: number; - p0: number; - p1: number; - p2: number; - p3: number; -}) { - const mt = 1 - progress; - return ( - mt * mt * mt * p0 + - 3 * mt * mt * progress * p1 + - 3 * mt * progress * progress * p2 + - progress * progress * progress * p3 - ); -} - -export function getDefaultRightHandle({ - leftKey, - rightKey, -}: { - leftKey: ScalarAnimationKey; - rightKey: ScalarAnimationKey; -}) { - const span = rightKey.time - leftKey.time; - const valueDelta = rightKey.value - leftKey.value; - return { - dt: span / 3, - dv: valueDelta / 3, - }; -} - -export function getDefaultLeftHandle({ - leftKey, - rightKey, -}: { - leftKey: ScalarAnimationKey; - rightKey: ScalarAnimationKey; -}) { - const span = rightKey.time - leftKey.time; - const valueDelta = rightKey.value - leftKey.value; - return { - dt: -span / 3, - dv: -valueDelta / 3, - }; -} - -export function solveBezierProgressForTime({ - time, - leftKey, - rightKey, -}: { - time: number; - leftKey: ScalarAnimationKey; - rightKey: ScalarAnimationKey; -}) { - let lower = 0; - let upper = 1; - const rightHandle = - leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); - const leftHandle = - rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); - - for (let iteration = 0; iteration < BEZIER_SOLVE_ITERATIONS; iteration++) { - const mid = (lower + upper) / 2; - const estimate = getBezierPoint({ - progress: mid, - p0: leftKey.time, - p1: leftKey.time + rightHandle.dt, - p2: rightKey.time + leftHandle.dt, - p3: rightKey.time, - }); - if (estimate < time) { - lower = mid; - } else { - upper = mid; - } - } - - return (lower + upper) / 2; -} diff --git a/apps/web/src/lib/animation/binding-values.ts b/apps/web/src/lib/animation/binding-values.ts deleted file mode 100644 index 66fb2b5d..00000000 --- a/apps/web/src/lib/animation/binding-values.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { converter, formatHex, formatHex8, parse } from "culori"; -import type { - AnimationBindingComponent, - AnimationBindingOfKind, - AnimationBindingInstance, - AnimationBindingKind, - ColorAnimationBinding, - DiscreteAnimationBinding, - NumberAnimationBinding, - AnimationPath, - AnimationValue, - DiscreteValue, - Vector2AnimationBinding, - VectorValue, -} from "@/lib/animation/types"; -import { clamp } from "@/utils/math"; - -interface LinearRgba { - r: number; - g: number; - b: number; - a: number; -} - -export type AnimationComponentValue = number | DiscreteValue; - -const toRgb = converter("rgb"); - -function srgbToLinear({ value }: { value: number }): number { - return value <= 0.04045 - ? value / 12.92 - : Math.pow((value + 0.055) / 1.055, 2.4); -} - -function linearToSrgb({ value }: { value: number }): number { - const clamped = clamp({ value, min: 0, max: 1 }); - return clamped <= 0.0031308 - ? clamped * 12.92 - : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055; -} - -function isRecord(value: unknown): value is Record<string, unknown> { - return typeof value === "object" && value !== null; -} - -export function isVectorValue(value: unknown): value is VectorValue { - return isRecord(value) && typeof value.x === "number" && typeof value.y === "number"; -} - -export type EasingMode = "independent" | "shared"; - -/** - * Declares how easing curves apply to a binding's components. - * "shared" means all components always use the same curve (e.g. color — you never - * want to ease R independently from G/B/A). "independent" means each component - * can have its own curve. - */ -export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode { - return kind === "color" ? "shared" : "independent"; -} - -export function getBindingComponentKeys({ - kind, -}: { - kind: AnimationBindingKind; -}): string[] { - if (kind === "vector2") { - return ["x", "y"]; - } - - if (kind === "color") { - return ["r", "g", "b", "a"]; - } - - return ["value"]; -} - -export function buildBindingChannelId({ - path, - componentKey, -}: { - path: AnimationPath; - componentKey: string; -}): string { - return `${path}:${componentKey}`; -} - -function createBindingComponent<TKey extends string>({ - path, - key, -}: { - path: AnimationPath; - key: TKey; -}): AnimationBindingComponent<TKey> { - return { - key, - channelId: buildBindingChannelId({ path, componentKey: key }), - }; -} - -function cloneBindingComponents<TKey extends string>({ - components, -}: { - components: AnimationBindingComponent<TKey>[]; -}): AnimationBindingComponent<TKey>[] { - return components.map((component) => ({ ...component })); -} - -const animationBindingFactories = { - color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({ - path, - kind: "color", - colorSpace: "srgb-linear", - components: [ - createBindingComponent({ path, key: "r" }), - createBindingComponent({ path, key: "g" }), - createBindingComponent({ path, key: "b" }), - createBindingComponent({ path, key: "a" }), - ], - }), - vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({ - path, - kind: "vector2", - components: [ - createBindingComponent({ path, key: "x" }), - createBindingComponent({ path, key: "y" }), - ], - }), - number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({ - path, - kind: "number", - components: [createBindingComponent({ path, key: "value" })], - }), - discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({ - path, - kind: "discrete", - components: [createBindingComponent({ path, key: "value" })], - }), -} satisfies { - [K in AnimationBindingKind]: ({ - path, - }: { - path: AnimationPath; - }) => AnimationBindingOfKind<K>; -}; - -export function createAnimationBinding<TKind extends AnimationBindingKind>({ - path, - kind, -}: { - path: AnimationPath; - kind: TKind; -}): AnimationBindingOfKind<TKind>; -export function createAnimationBinding({ - path, - kind, -}: { - path: AnimationPath; - kind: AnimationBindingKind; -}): AnimationBindingInstance { - return animationBindingFactories[kind]({ path }); -} - -const animationBindingCloners = { - color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({ - ...binding, - components: cloneBindingComponents({ - components: binding.components, - }), - }), - vector2: ({ - binding, - }: { - binding: Vector2AnimationBinding; - }): Vector2AnimationBinding => ({ - ...binding, - components: cloneBindingComponents({ - components: binding.components, - }), - }), - number: ({ - binding, - }: { - binding: NumberAnimationBinding; - }): NumberAnimationBinding => ({ - ...binding, - components: cloneBindingComponents({ - components: binding.components, - }), - }), - discrete: ({ - binding, - }: { - binding: DiscreteAnimationBinding; - }): DiscreteAnimationBinding => ({ - ...binding, - components: cloneBindingComponents({ - components: binding.components, - }), - }), -} satisfies { - [K in AnimationBindingKind]: ({ - binding, - }: { - binding: AnimationBindingOfKind<K>; - }) => AnimationBindingOfKind<K>; -}; - -export function cloneAnimationBinding<TKind extends AnimationBindingKind>({ - binding, -}: { - binding: AnimationBindingOfKind<TKind>; -}): AnimationBindingOfKind<TKind>; -export function cloneAnimationBinding({ - binding, -}: { - binding: AnimationBindingInstance; -}): AnimationBindingInstance { - switch (binding.kind) { - case "color": - return animationBindingCloners.color({ binding }); - case "vector2": - return animationBindingCloners.vector2({ binding }); - case "number": - return animationBindingCloners.number({ binding }); - case "discrete": - return animationBindingCloners.discrete({ binding }); - } -} - -export function parseColorToLinearRgba({ - color, -}: { - color: string; -}): LinearRgba | null { - const parsed = parse(color); - const rgb = parsed ? toRgb(parsed) : null; - if (!rgb) { - return null; - } - - return { - r: srgbToLinear({ value: rgb.r ?? 0 }), - g: srgbToLinear({ value: rgb.g ?? 0 }), - b: srgbToLinear({ value: rgb.b ?? 0 }), - a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }), - }; -} - -export function formatLinearRgba({ - color, -}: { - color: LinearRgba; -}): string { - const rgb = { - mode: "rgb", - r: linearToSrgb({ value: color.r }), - g: linearToSrgb({ value: color.g }), - b: linearToSrgb({ value: color.b }), - alpha: clamp({ value: color.a, min: 0, max: 1 }), - } as const; - return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb); -} - -export function decomposeAnimationValue({ - kind, - value, -}: { - kind: AnimationBindingKind; - value: AnimationValue; -}): Record<string, AnimationComponentValue> | null { - if (kind === "number") { - return typeof value === "number" ? { value } : null; - } - - if (kind === "vector2") { - return isVectorValue(value) ? { x: value.x, y: value.y } : null; - } - - if (kind === "color") { - if (typeof value !== "string") { - return null; - } - const parsed = parseColorToLinearRgba({ color: value }); - if (!parsed) { - return null; - } - return { - r: parsed.r, - g: parsed.g, - b: parsed.b, - a: parsed.a, - }; - } - - return typeof value === "string" || typeof value === "boolean" - ? { value } - : null; -} - -export function composeAnimationValue({ - binding, - componentValues, -}: { - binding: AnimationBindingInstance; - componentValues: Record<string, AnimationComponentValue | undefined>; -}): AnimationValue | null { - if (binding.kind === "number") { - const value = componentValues.value; - return typeof value === "number" ? value : null; - } - - if (binding.kind === "vector2") { - const x = componentValues.x; - const y = componentValues.y; - return typeof x === "number" && typeof y === "number" ? { x, y } : null; - } - - if (binding.kind === "color") { - const r = componentValues.r; - const g = componentValues.g; - const b = componentValues.b; - const a = componentValues.a; - if ( - typeof r !== "number" || - typeof g !== "number" || - typeof b !== "number" || - typeof a !== "number" - ) { - return null; - } - return formatLinearRgba({ - color: { - r, - g, - b, - a, - }, - }); - } - - const value = componentValues.value; - return typeof value === "string" || typeof value === "boolean" ? value : null; -} diff --git a/apps/web/src/lib/animation/curve-bridge.ts b/apps/web/src/lib/animation/curve-bridge.ts deleted file mode 100644 index 385fb448..00000000 --- a/apps/web/src/lib/animation/curve-bridge.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - getDefaultLeftHandle, - getDefaultRightHandle, -} from "@/lib/animation/bezier"; -import type { - CurveHandle, - NormalizedCubicBezier, - ScalarAnimationKey, -} from "@/lib/animation/types"; - -const VALUE_EPSILON = 1e-6; - -function clamp01({ value }: { value: number }): number { - return Math.max(0, Math.min(1, value)); -} - -export function getNormalizedCubicBezierForScalarSegment({ - leftKey, - rightKey, - referenceSpanValue, -}: { - leftKey: ScalarAnimationKey; - rightKey: ScalarAnimationKey; - /** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */ - referenceSpanValue?: number; -}): NormalizedCubicBezier | null { - const spanTime = rightKey.time - leftKey.time; - const spanValue = rightKey.value - leftKey.value; - const effectiveSpanValue = - Math.abs(spanValue) > VALUE_EPSILON - ? spanValue - : referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON - ? referenceSpanValue - : null; - - if (spanTime === 0 || effectiveSpanValue === null) { - return null; - } - - const rightHandle = - leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); - const leftHandle = - rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); - - return [ - clamp01({ value: rightHandle.dt / spanTime }), - rightHandle.dv / effectiveSpanValue, - clamp01({ value: 1 + leftHandle.dt / spanTime }), - 1 + leftHandle.dv / effectiveSpanValue, - ]; -} - -export function getCurveHandlesForNormalizedCubicBezier({ - leftKey, - rightKey, - cubicBezier, - referenceSpanValue, -}: { - leftKey: ScalarAnimationKey; - rightKey: ScalarAnimationKey; - cubicBezier: NormalizedCubicBezier; - /** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */ - referenceSpanValue?: number; -}): { - rightHandle: CurveHandle; - leftHandle: CurveHandle; -} | null { - const spanTime = rightKey.time - leftKey.time; - const spanValue = rightKey.value - leftKey.value; - const effectiveSpanValue = - Math.abs(spanValue) > VALUE_EPSILON - ? spanValue - : referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON - ? referenceSpanValue - : null; - - if (spanTime === 0 || effectiveSpanValue === null) { - return null; - } - - const [rawX1, y1, rawX2, y2] = cubicBezier; - const x1 = clamp01({ value: rawX1 }); - const x2 = clamp01({ value: rawX2 }); - - return { - rightHandle: { - dt: spanTime * x1, - dv: effectiveSpanValue * y1, - }, - leftHandle: { - dt: spanTime * (x2 - 1), - dv: effectiveSpanValue * (y2 - 1), - }, - }; -} diff --git a/apps/web/src/lib/animation/effect-param-channel.ts b/apps/web/src/lib/animation/effect-param-channel.ts deleted file mode 100644 index 3080e6ff..00000000 --- a/apps/web/src/lib/animation/effect-param-channel.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { ParamValues } from "@/lib/params"; -import type { Effect } from "@/lib/effects/types"; -import type { - ElementAnimations, - EffectParamPath, -} from "@/lib/animation/types"; -import { removeElementKeyframe } from "./keyframes"; -import { resolveAnimationPathValueAtTime } from "./resolve"; - -export const EFFECT_PARAM_PATH_PREFIX = "effects."; -export const EFFECT_PARAM_PATH_SUFFIX = ".params."; - -export function buildEffectParamPath({ - effectId, - paramKey, -}: { - effectId: string; - paramKey: string; -}): EffectParamPath { - return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`; -} - -export function isEffectParamPath( - propertyPath: string, -): propertyPath is EffectParamPath { - return ( - propertyPath.startsWith(EFFECT_PARAM_PATH_PREFIX) && - propertyPath.includes(EFFECT_PARAM_PATH_SUFFIX) - ); -} - -export function parseEffectParamPath({ - propertyPath, -}: { - propertyPath: string; -}): { effectId: string; paramKey: string } | null { - if (!isEffectParamPath(propertyPath)) { - return null; - } - - const withoutPrefix = propertyPath.slice(EFFECT_PARAM_PATH_PREFIX.length); - const separatorIndex = withoutPrefix.indexOf(EFFECT_PARAM_PATH_SUFFIX); - if (separatorIndex <= 0) { - return null; - } - - const effectId = withoutPrefix.slice(0, separatorIndex); - const paramKey = withoutPrefix.slice( - separatorIndex + EFFECT_PARAM_PATH_SUFFIX.length, - ); - if (!effectId || !paramKey) { - return null; - } - - return { effectId, paramKey }; -} - -export function resolveEffectParamsAtTime({ - effect, - animations, - localTime, -}: { - effect: Effect; - animations: ElementAnimations | undefined; - localTime: number; -}): ParamValues { - const resolved: ParamValues = {}; - - for (const [paramKey, staticValue] of Object.entries(effect.params)) { - const path = buildEffectParamPath({ effectId: effect.id, paramKey }); - resolved[paramKey] = animations?.bindings[path] - ? resolveAnimationPathValueAtTime({ - animations, - propertyPath: path, - localTime, - fallbackValue: staticValue, - }) - : staticValue; - } - - return resolved; -} - -export function removeEffectParamKeyframe({ - animations, - effectId, - paramKey, - keyframeId, -}: { - animations: ElementAnimations | undefined; - effectId: string; - paramKey: string; - keyframeId: string; -}): ElementAnimations | undefined { - return removeElementKeyframe({ - animations, - propertyPath: buildEffectParamPath({ effectId, paramKey }), - keyframeId, - }); -} diff --git a/apps/web/src/lib/animation/graph-channels.ts b/apps/web/src/lib/animation/graph-channels.ts deleted file mode 100644 index 19d53e27..00000000 --- a/apps/web/src/lib/animation/graph-channels.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { - AnimationBindingInstance, - AnimationPath, - ElementAnimations, - ScalarAnimationChannel, - ScalarGraphChannel, - ScalarGraphKeyframeContext, -} from "@/lib/animation/types"; - -export interface EditableScalarChannels { - binding: AnimationBindingInstance; - channels: ScalarGraphChannel[]; -} - -function isScalarAnimationChannel( - channel: ElementAnimations["channels"][string], -): channel is ScalarAnimationChannel { - return channel?.kind === "scalar"; -} - -export function getEditableScalarChannels({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; -}): EditableScalarChannels | null { - const binding = animations?.bindings[propertyPath]; - if (!binding) { - return null; - } - - const channels = binding.components.flatMap((component) => { - const channel = animations?.channels[component.channelId]; - if (!isScalarAnimationChannel(channel)) { - return []; - } - - return [ - { - propertyPath, - componentKey: component.key, - channelId: component.channelId, - channel, - } satisfies ScalarGraphChannel, - ]; - }); - - return { binding, channels }; -} - -export function getEditableScalarChannel({ - animations, - propertyPath, - componentKey, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - componentKey: string; -}): ScalarGraphChannel | null { - const result = getEditableScalarChannels({ animations, propertyPath }); - return result?.channels.find((channel) => channel.componentKey === componentKey) ?? null; -} - -export function getScalarKeyframeContext({ - animations, - propertyPath, - componentKey, - keyframeId, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - componentKey: string; - keyframeId: string; -}): ScalarGraphKeyframeContext | null { - const scalarChannel = getEditableScalarChannel({ - animations, - propertyPath, - componentKey, - }); - if (!scalarChannel) { - return null; - } - - const keyframeIndex = scalarChannel.channel.keys.findIndex( - (keyframe) => keyframe.id === keyframeId, - ); - if (keyframeIndex < 0) { - return null; - } - - return { - ...scalarChannel, - keyframe: scalarChannel.channel.keys[keyframeIndex], - keyframeIndex, - previousKey: scalarChannel.channel.keys[keyframeIndex - 1] ?? null, - nextKey: scalarChannel.channel.keys[keyframeIndex + 1] ?? null, - }; -} diff --git a/apps/web/src/lib/animation/graphic-param-channel.ts b/apps/web/src/lib/animation/graphic-param-channel.ts deleted file mode 100644 index de595060..00000000 --- a/apps/web/src/lib/animation/graphic-param-channel.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { - ElementAnimations, - GraphicParamPath, -} from "@/lib/animation/types"; -import type { ParamValues } from "@/lib/params"; -import { - getGraphicDefinition, - resolveGraphicParams, -} from "@/lib/graphics"; -import { resolveAnimationPathValueAtTime } from "./resolve"; - -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 }); - if (!element.animations?.bindings[path]) { - continue; - } - - resolved[param.key] = resolveAnimationPathValueAtTime({ - animations: element.animations, - propertyPath: path, - localTime: Math.max(0, localTime), - fallbackValue: baseParams[param.key] ?? param.default, - }); - } - - return resolved; -} diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts deleted file mode 100644 index 384f3d17..00000000 --- a/apps/web/src/lib/animation/index.ts +++ /dev/null @@ -1,96 +0,0 @@ -export { - getChannelValueAtTime, - getDiscreteChannelValueAtTime, - getScalarChannelValueAtTime, - getScalarSegmentInterpolation, - normalizeChannel, -} from "./interpolation"; - -export { - clampAnimationsToDuration, - cloneAnimations, - getChannel, - removeElementKeyframe, - retimeElementKeyframe, - setBindingComponentChannel, - setChannel, - splitAnimationsAtTime, - updateScalarKeyframeCurve, - upsertElementKeyframe, - upsertPathKeyframe, -} from "./keyframes"; - -export { - getElementLocalTime, - resolveAnimationPathValueAtTime, - resolveColorAtTime, - resolveNumberAtTime, - resolveOpacityAtTime, - resolveTransformAtTime, -} from "./resolve"; - -export { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getDefaultInterpolationForProperty, - getElementBaseValueForProperty, - isAnimationPropertyPath, - supportsAnimationProperty, - type AnimationPropertyDefinition, - type NumericSpec, - withElementBaseValueForProperty, -} from "./property-registry"; - -export { - getElementKeyframes, - getKeyframeById, - getKeyframeAtTime, - hasKeyframesForPath, -} from "./keyframe-query"; - -export { - type EditableScalarChannels, - getEditableScalarChannel, - getEditableScalarChannels, - getScalarKeyframeContext, -} from "./graph-channels"; - -export { - getCurveHandlesForNormalizedCubicBezier, - getNormalizedCubicBezierForScalarSegment, -} from "./curve-bridge"; - -export { - buildGraphicParamPath, - isGraphicParamPath, - parseGraphicParamPath, - resolveGraphicParamsAtTime, -} from "./graphic-param-channel"; - -export { - buildEffectParamPath, - isEffectParamPath, - parseEffectParamPath, - removeEffectParamKeyframe, - resolveEffectParamsAtTime, -} from "./effect-param-channel"; - -export { - isAnimationPath, - coerceAnimationValueForParam, - resolveAnimationTarget, - getParamValueKind, - getParamDefaultInterpolation, - type AnimationPathDescriptor, -} from "./target-resolver"; - -export { - getGroupKeyframesAtTime, - hasGroupKeyframeAtTime, - type GroupKeyframeRef, -} from "./property-groups"; - -export { - type EasingMode, - getEasingModeForKind, -} from "./binding-values"; diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts deleted file mode 100644 index 1a3c0fed..00000000 --- a/apps/web/src/lib/animation/interpolation.ts +++ /dev/null @@ -1,375 +0,0 @@ -import type { - AnimationChannel, - AnimationInterpolation, - AnimationValue, - DiscreteAnimationChannel, - DiscreteValue, - ScalarAnimationChannel, - ScalarAnimationKey, - ScalarSegmentType, -} from "@/lib/animation/types"; -import { clamp } from "@/utils/math"; -import { - getBezierPoint, - getDefaultLeftHandle, - getDefaultRightHandle, - solveBezierProgressForTime, -} from "./bezier"; - -function byTimeAscending({ - leftTime, - rightTime, -}: { - leftTime: number; - rightTime: number; -}): number { - return leftTime - rightTime; -} - -function isWithinTimePair({ - time, - leftTime, - rightTime, -}: { - time: number; - leftTime: number; - rightTime: number; -}): boolean { - return time >= leftTime && time <= rightTime; -} - -function lerpNumber({ - leftValue, - rightValue, - progress, -}: { - leftValue: number; - rightValue: number; - progress: number; -}): number { - return leftValue + (rightValue - leftValue) * progress; -} - -function normalizeRightHandle({ - handle, - leftKey, - rightKey, -}: { - handle: ScalarAnimationKey["rightHandle"]; - leftKey: ScalarAnimationKey; - rightKey: ScalarAnimationKey; -}) { - if (!handle) { - return undefined; - } - - const span = Math.max(1, rightKey.time - leftKey.time); - return { - dt: Math.min(span, Math.max(0, handle.dt)), - dv: handle.dv, - }; -} - -function normalizeLeftHandle({ - handle, - leftKey, - rightKey, -}: { - handle: ScalarAnimationKey["leftHandle"]; - leftKey: ScalarAnimationKey; - rightKey: ScalarAnimationKey; -}) { - if (!handle) { - return undefined; - } - - const span = Math.max(1, rightKey.time - leftKey.time); - return { - dt: Math.max(-span, Math.min(0, handle.dt)), - dv: handle.dv, - }; -} - -function normalizeScalarKey({ - key, -}: { - key: ScalarAnimationKey; -}): ScalarAnimationKey { - return { - ...key, - tangentMode: key.tangentMode ?? "flat", - segmentToNext: key.segmentToNext ?? "linear", - }; -} - -function normalizeScalarChannel({ - channel, -}: { - channel: ScalarAnimationChannel; -}): ScalarAnimationChannel { - const sortedKeys = [...channel.keys] - .map((key) => normalizeScalarKey({ key })) - .sort((leftKey, rightKey) => - byTimeAscending({ - leftTime: leftKey.time, - rightTime: rightKey.time, - }), - ); - const nextKeys = sortedKeys.map((key, index) => { - const previousKey = sortedKeys[index - 1]; - const nextKey = sortedKeys[index + 1]; - return { - ...key, - leftHandle: - previousKey != null - ? normalizeLeftHandle({ - handle: key.leftHandle, - leftKey: previousKey, - rightKey: key, - }) - : undefined, - rightHandle: - nextKey != null - ? normalizeRightHandle({ - handle: key.rightHandle, - leftKey: key, - rightKey: nextKey, - }) - : undefined, - }; - }); - - return { - ...channel, - keys: nextKeys, - }; -} - -export function normalizeChannel<TChannel extends AnimationChannel>({ - channel, -}: { - channel: TChannel; -}): TChannel { - if (channel.kind === "scalar") { - return normalizeScalarChannel({ - channel, - }) as TChannel; - } - - return { - ...channel, - keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) => - byTimeAscending({ - leftTime: leftKeyframe.time, - rightTime: rightKeyframe.time, - }), - ), - } as TChannel; -} - -function extrapolateScalarEdge({ - mode, - edgeKey, - neighborKey, - time, -}: { - mode: "hold" | "linear"; - edgeKey: ScalarAnimationKey; - neighborKey: ScalarAnimationKey | undefined; - time: number; -}) { - if (mode === "hold" || !neighborKey) { - return edgeKey.value; - } - - const span = neighborKey.time - edgeKey.time; - if (span === 0) { - return edgeKey.value; - } - - return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value); -} - -export function getScalarSegmentInterpolation({ - segment, -}: { - segment: ScalarSegmentType; -}): AnimationInterpolation { - if (segment === "step") { - return "hold"; - } - - return segment === "bezier" ? "bezier" : "linear"; -} - -export function getScalarChannelValueAtTime({ - channel, - time, - fallbackValue, -}: { - channel: ScalarAnimationChannel | undefined; - time: number; - fallbackValue: number; -}): number { - if (!channel || channel.keys.length === 0) { - return fallbackValue; - } - - const normalizedChannel = normalizeChannel({ - channel, - }); - const firstKey = normalizedChannel.keys[0]; - const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1]; - if (!firstKey || !lastKey) { - return fallbackValue; - } - - if (time <= firstKey.time) { - if (time < firstKey.time) { - return extrapolateScalarEdge({ - mode: normalizedChannel.extrapolation?.before ?? "hold", - edgeKey: firstKey, - neighborKey: normalizedChannel.keys[1], - time, - }); - } - - return firstKey.value; - } - - if (time >= lastKey.time) { - if (time > lastKey.time) { - return extrapolateScalarEdge({ - mode: normalizedChannel.extrapolation?.after ?? "hold", - edgeKey: lastKey, - neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2], - time, - }); - } - - return lastKey.value; - } - - for ( - let keyIndex = 0; - keyIndex < normalizedChannel.keys.length - 1; - keyIndex++ - ) { - const leftKey = normalizedChannel.keys[keyIndex]; - const rightKey = normalizedChannel.keys[keyIndex + 1]; - if (time === rightKey.time) { - return rightKey.value; - } - - if ( - !isWithinTimePair({ - time, - leftTime: leftKey.time, - rightTime: rightKey.time, - }) - ) { - continue; - } - - if (leftKey.segmentToNext === "step") { - return leftKey.value; - } - - const span = rightKey.time - leftKey.time; - if (span === 0) { - return rightKey.value; - } - - const progress = clamp({ - value: (time - leftKey.time) / span, - min: 0, - max: 1, - }); - if (leftKey.segmentToNext === "linear") { - return lerpNumber({ - leftValue: leftKey.value, - rightValue: rightKey.value, - progress, - }); - } - - const curveProgress = solveBezierProgressForTime({ - time, - leftKey, - rightKey, - }); - const rightHandle = - leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); - const leftHandle = - rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); - return getBezierPoint({ - progress: curveProgress, - p0: leftKey.value, - p1: leftKey.value + rightHandle.dv, - p2: rightKey.value + leftHandle.dv, - p3: rightKey.value, - }); - } - - return lastKey.value; -} - -export function getDiscreteChannelValueAtTime({ - channel, - time, - fallbackValue, -}: { - channel: DiscreteAnimationChannel | undefined; - time: number; - fallbackValue: DiscreteValue; -}): DiscreteValue { - if (!channel || channel.keys.length === 0) { - return fallbackValue; - } - - const normalizedChannel = normalizeChannel({ - channel, - }); - let currentValue = fallbackValue; - for (const key of normalizedChannel.keys) { - if (time < key.time) { - break; - } - currentValue = key.value; - } - return currentValue; -} - -export function getChannelValueAtTime({ - channel, - time, - fallbackValue, -}: { - channel: AnimationChannel | undefined; - time: number; - fallbackValue: AnimationValue; -}): AnimationValue { - if (!channel || channel.keys.length === 0) { - return fallbackValue; - } - - if (channel.kind === "scalar") { - return typeof fallbackValue === "number" - ? getScalarChannelValueAtTime({ - channel, - time, - fallbackValue, - }) - : fallbackValue; - } - - if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") { - return fallbackValue; - } - - return getDiscreteChannelValueAtTime({ - channel, - time, - fallbackValue, - }); -} diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts deleted file mode 100644 index dbcd041c..00000000 --- a/apps/web/src/lib/animation/keyframe-query.ts +++ /dev/null @@ -1,294 +0,0 @@ -import type { - AnimationBindingInstance, - AnimationChannel, - AnimationPath, - ElementAnimations, - ElementKeyframe, -} from "@/lib/animation/types"; -import { - type AnimationComponentValue, - composeAnimationValue, -} from "./binding-values"; -import { - getChannelValueAtTime, - getScalarSegmentInterpolation, -} from "./interpolation"; -import { isAnimationPath } from "./target-resolver"; - -function getBindingFallbackValue({ - channel, -}: { - channel: ElementAnimations["channels"][string]; -}) { - if (!channel || channel.keys.length === 0) { - return channel?.kind === "discrete" ? false : 0; - } - - return channel.keys[0].value; -} - -interface BindingKeyframeMatch { - componentIndex: number; - channel: AnimationChannel; - keyframe: AnimationChannel["keys"][number]; -} - -function getBindingKeyframeMatches({ - animations, - binding, -}: { - animations: ElementAnimations; - binding: AnimationBindingInstance; -}): BindingKeyframeMatch[] { - return binding.components.flatMap((component, componentIndex) => { - const channel = animations.channels[component.channelId]; - if (!channel || channel.keys.length === 0) { - return []; - } - - return channel.keys.map((keyframe) => ({ - componentIndex, - channel, - keyframe, - })); - }); -} - -function getUniqueBindingKeyframeMatches({ - animations, - binding, -}: { - animations: ElementAnimations; - binding: AnimationBindingInstance; -}): BindingKeyframeMatch[] { - const sortedMatches = getBindingKeyframeMatches({ - animations, - binding, - }).sort( - (leftMatch, rightMatch) => - leftMatch.keyframe.time - rightMatch.keyframe.time || - leftMatch.componentIndex - rightMatch.componentIndex, - ); - const uniqueMatches: BindingKeyframeMatch[] = []; - - for (const match of sortedMatches) { - const previousMatch = uniqueMatches[uniqueMatches.length - 1]; - if ( - !previousMatch || - previousMatch.keyframe.time !== match.keyframe.time - ) { - uniqueMatches.push(match); - continue; - } - - if ( - previousMatch.componentIndex !== 0 && - match.componentIndex === 0 - ) { - uniqueMatches[uniqueMatches.length - 1] = match; - } - } - - return uniqueMatches; -} - -function getPreferredBindingKeyframeMatch({ - matches, -}: { - matches: BindingKeyframeMatch[]; -}): BindingKeyframeMatch | null { - return ( - matches.find((match) => match.componentIndex === 0) ?? - matches[0] ?? - null - ); -} - -function getComposedBindingValueAtTime({ - animations, - binding, - time, -}: { - animations: ElementAnimations; - binding: AnimationBindingInstance; - time: number; -}) { - const componentValues = Object.fromEntries( - binding.components.map((component) => { - const channel = animations.channels[component.channelId]; - return [ - component.key, - getChannelValueAtTime({ - channel, - time, - fallbackValue: getBindingFallbackValue({ channel }), - }), - ]; - }), - ) as Record<string, AnimationComponentValue | undefined>; - - return composeAnimationValue({ - binding, - componentValues, - }); -} - -function getKeyframeInterpolation({ - channel, - keyframe, -}: { - channel: AnimationChannel; - keyframe: AnimationChannel["keys"][number]; -}) { - return channel.kind === "scalar" && "segmentToNext" in keyframe - ? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext }) - : "hold"; -} - -function toElementKeyframe({ - animations, - binding, - propertyPath, - keyframeMatch, -}: { - animations: ElementAnimations; - binding: AnimationBindingInstance; - propertyPath: AnimationPath; - keyframeMatch: BindingKeyframeMatch; -}): ElementKeyframe | null { - const value = getComposedBindingValueAtTime({ - animations, - binding, - time: keyframeMatch.keyframe.time, - }); - if (value === null) { - return null; - } - - return { - propertyPath, - id: keyframeMatch.keyframe.id, - time: keyframeMatch.keyframe.time, - value, - interpolation: getKeyframeInterpolation({ - channel: keyframeMatch.channel, - keyframe: keyframeMatch.keyframe, - }), - }; -} - -export function getElementKeyframes({ - animations, -}: { - animations: ElementAnimations | undefined; -}): ElementKeyframe[] { - if (!animations) { - return []; - } - - return Object.entries(animations.bindings).flatMap( - ([propertyPath, binding]) => { - if (!binding || !isAnimationPath(propertyPath)) { - return []; - } - - return getUniqueBindingKeyframeMatches({ - animations, - binding, - }).flatMap((keyframeMatch) => { - const keyframe = toElementKeyframe({ - animations, - binding, - propertyPath, - keyframeMatch, - }); - if (!keyframe) { - return []; - } - - return [keyframe]; - }); - }, - ); -} - -export function hasKeyframesForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; -}): boolean { - const binding = animations?.bindings[propertyPath]; - if (!binding) { - return false; - } - - return binding.components.some((component) => - Boolean(animations?.channels[component.channelId]?.keys.length), - ); -} - -export function getKeyframeAtTime({ - animations, - propertyPath, - time, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - time: number; -}): ElementKeyframe | null { - const binding = animations?.bindings[propertyPath]; - if (!binding) { - return null; - } - - const keyframeMatch = getPreferredBindingKeyframeMatch({ - matches: getBindingKeyframeMatches({ - animations, - binding, - }).filter(({ keyframe }) => keyframe.time === time), - }); - if (!keyframeMatch) { - return null; - } - - return toElementKeyframe({ - animations, - binding, - propertyPath, - keyframeMatch, - }); -} - -export function getKeyframeById({ - animations, - propertyPath, - keyframeId, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - keyframeId: string; -}): ElementKeyframe | null { - const binding = animations?.bindings[propertyPath]; - if (!binding) { - return null; - } - - const keyframeMatch = getPreferredBindingKeyframeMatch({ - matches: getBindingKeyframeMatches({ - animations, - binding, - }).filter(({ keyframe }) => keyframe.id === keyframeId), - }); - if (!keyframeMatch) { - return null; - } - - return toElementKeyframe({ - animations, - binding, - propertyPath, - keyframeMatch, - }); -} diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts deleted file mode 100644 index a5efb6ee..00000000 --- a/apps/web/src/lib/animation/keyframes.ts +++ /dev/null @@ -1,1338 +0,0 @@ -import type { - AnimationBindingInstance, - AnimationBindingKind, - AnimationChannel, - AnimationInterpolation, - AnimationPath, - AnimationPropertyPath, - AnimationValue, - DiscreteAnimationChannel, - DiscreteAnimationKey, - ElementAnimations, - ScalarAnimationChannel, - ScalarAnimationKey, - ScalarCurveKeyframePatch, - ScalarSegmentType, -} from "@/lib/animation/types"; -import { generateUUID } from "@/utils/id"; -import { - cloneAnimationBinding, - createAnimationBinding, - decomposeAnimationValue, -} from "./binding-values"; -import { - getBezierPoint, - getDefaultLeftHandle, - getDefaultRightHandle, - solveBezierProgressForTime, -} from "./bezier"; -import { - getChannelValueAtTime, - getScalarSegmentInterpolation, - normalizeChannel, -} from "./interpolation"; -import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, -} from "./property-registry"; - -function isNearlySameTime({ - leftTime, - rightTime, -}: { - leftTime: number; - rightTime: number; -}): boolean { - return leftTime === rightTime; -} - -function hasChannelKeys({ - channel, -}: { - channel: AnimationChannel | undefined; -}): boolean { - return Boolean(channel && channel.keys.length > 0); -} - -function toAnimation({ - animations, -}: { - animations: ElementAnimations; -}): ElementAnimations | undefined { - const nextBindings = Object.fromEntries( - Object.entries(animations.bindings).filter(([, binding]) => binding), - ); - const nextChannels = Object.fromEntries( - Object.entries(animations.channels).filter(([, channel]) => - hasChannelKeys({ channel }), - ), - ); - if (Object.keys(nextBindings).length === 0 || Object.keys(nextChannels).length === 0) { - return undefined; - } - - return { - bindings: nextBindings, - channels: nextChannels, - }; -} - -function cloneAnimationsState({ - animations, -}: { - animations: ElementAnimations | undefined; -}): ElementAnimations { - return { - bindings: { ...(animations?.bindings ?? {}) }, - channels: { ...(animations?.channels ?? {}) }, - }; -} - -function getBindingChannelKind({ - kind, -}: { - kind: AnimationBindingKind; -}): AnimationChannel["kind"] { - return kind === "discrete" ? "discrete" : "scalar"; -} - -function getPrimaryComponent({ - binding, -}: { - binding: AnimationBindingInstance; -}) { - return binding.components[0] ?? null; -} - -function getPrimaryChannelId({ - binding, -}: { - binding: AnimationBindingInstance; -}) { - return getPrimaryComponent({ binding })?.channelId ?? null; -} - -function getScalarSegmentType({ - interpolation, -}: { - interpolation: AnimationInterpolation; -}): ScalarSegmentType { - if (interpolation === "hold") { - return "step"; - } - return interpolation === "bezier" ? "bezier" : "linear"; -} - -function getInterpolationForBinding({ - kind, - interpolation, -}: { - kind: AnimationBindingKind; - interpolation: AnimationInterpolation | undefined; -}): AnimationInterpolation { - if (kind === "discrete") { - return "hold"; - } - - if ( - interpolation === "linear" || - interpolation === "hold" || - interpolation === "bezier" - ) { - return interpolation; - } - - return "linear"; -} - -function createEmptyChannelForBindingKind({ - kind, -}: { - kind: AnimationBindingKind; -}): AnimationChannel { - if (kind === "discrete") { - return { - kind: "discrete", - keys: [], - } satisfies DiscreteAnimationChannel; - } - - return { - kind: "scalar", - keys: [], - } satisfies ScalarAnimationChannel; -} - -function createScalarKey({ - id, - time, - value, - interpolation, - previousKey, -}: { - id: string; - time: number; - value: number; - interpolation?: AnimationInterpolation; - previousKey?: ScalarAnimationKey; -}): ScalarAnimationKey { - return { - id, - time, - value, - leftHandle: previousKey?.leftHandle, - rightHandle: previousKey?.rightHandle, - segmentToNext: - previousKey?.segmentToNext ?? - getScalarSegmentType({ interpolation: interpolation ?? "linear" }), - tangentMode: previousKey?.tangentMode ?? "flat", - }; -} - -function createDiscreteKey({ - id, - time, - value, -}: { - id: string; - time: number; - value: string | boolean; -}): DiscreteAnimationKey { - return { - id, - time, - value, - }; -} - -function getBinding({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; -}): AnimationBindingInstance | undefined { - return animations?.bindings[propertyPath]; -} - -function getChannelById({ - animations, - channelId, -}: { - animations: ElementAnimations | undefined; - channelId: string; -}): AnimationChannel | undefined { - return animations?.channels[channelId]; -} - -function getBindingComponent({ - binding, - componentKey, -}: { - binding: AnimationBindingInstance; - componentKey: string; -}) { - return binding.components.find((component) => component.key === componentKey) ?? null; -} - -function getTargetKeyMetadata({ - channel, - time, - keyframeId, -}: { - channel: AnimationChannel | undefined; - time: number; - keyframeId?: string; -}) { - const normalizedChannel = - channel != null ? normalizeChannel({ channel }) : undefined; - const keys = normalizedChannel?.keys ?? []; - if (keyframeId) { - const keyById = keys.find((key) => key.id === keyframeId); - if (keyById) { - return { - id: keyById.id, - time, - }; - } - } - - const keyAtTime = keys.find((key) => - isNearlySameTime({ leftTime: key.time, rightTime: time }), - ); - if (keyAtTime) { - return { - id: keyAtTime.id, - time: keyAtTime.time, - }; - } - - return { - id: keyframeId ?? generateUUID(), - time, - }; -} - -function upsertDiscreteChannelKey({ - channel, - time, - value, - keyframeId, -}: { - channel: DiscreteAnimationChannel | undefined; - time: number; - value: string | boolean; - keyframeId?: string; -}): DiscreteAnimationChannel { - const normalizedChannel = normalizeChannel({ - channel: channel ?? { kind: "discrete", keys: [] }, - }); - const keys = [...normalizedChannel.keys]; - if (keyframeId) { - const existingIndex = keys.findIndex((key) => key.id === keyframeId); - if (existingIndex >= 0) { - keys[existingIndex] = createDiscreteKey({ - id: keys[existingIndex].id, - time, - value, - }); - return normalizeChannel({ - channel: { kind: "discrete", keys }, - }); - } - } - - const existingAtTimeIndex = keys.findIndex((key) => - isNearlySameTime({ leftTime: key.time, rightTime: time }), - ); - if (existingAtTimeIndex >= 0) { - keys[existingAtTimeIndex] = createDiscreteKey({ - id: keys[existingAtTimeIndex].id, - time: keys[existingAtTimeIndex].time, - value, - }); - return normalizeChannel({ - channel: { kind: "discrete", keys }, - }); - } - - keys.push( - createDiscreteKey({ - id: keyframeId ?? generateUUID(), - time, - value, - }), - ); - return normalizeChannel({ - channel: { kind: "discrete", keys }, - }); -} - -function upsertScalarChannelKey({ - channel, - time, - value, - interpolation, - defaultInterpolation, - keyframeId, -}: { - channel: ScalarAnimationChannel | undefined; - time: number; - value: number; - interpolation?: AnimationInterpolation; - defaultInterpolation?: AnimationInterpolation; - keyframeId?: string; -}): ScalarAnimationChannel { - const normalizedChannel = normalizeChannel({ - channel: channel ?? { kind: "scalar", keys: [] }, - }); - const keys = [...normalizedChannel.keys]; - if (keyframeId) { - const existingIndex = keys.findIndex((key) => key.id === keyframeId); - if (existingIndex >= 0) { - keys[existingIndex] = createScalarKey({ - id: keys[existingIndex].id, - time, - value, - interpolation, - previousKey: - interpolation != null - ? { - ...keys[existingIndex], - segmentToNext: getScalarSegmentType({ interpolation }), - } - : keys[existingIndex], - }); - return normalizeChannel({ - channel: { - kind: "scalar", - keys, - extrapolation: normalizedChannel.extrapolation, - }, - }); - } - } - - const existingAtTimeIndex = keys.findIndex((key) => - isNearlySameTime({ leftTime: key.time, rightTime: time }), - ); - if (existingAtTimeIndex >= 0) { - keys[existingAtTimeIndex] = createScalarKey({ - id: keys[existingAtTimeIndex].id, - time: keys[existingAtTimeIndex].time, - value, - interpolation, - previousKey: - interpolation != null - ? { - ...keys[existingAtTimeIndex], - segmentToNext: getScalarSegmentType({ interpolation }), - } - : keys[existingAtTimeIndex], - }); - return normalizeChannel({ - channel: { - kind: "scalar", - keys, - extrapolation: normalizedChannel.extrapolation, - }, - }); - } - - keys.push( - createScalarKey({ - id: keyframeId ?? generateUUID(), - time, - value, - interpolation: interpolation ?? defaultInterpolation, - }), - ); - return normalizeChannel({ - channel: { - kind: "scalar", - keys, - extrapolation: normalizedChannel.extrapolation, - }, - }); -} - -export function getChannel({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; -}): AnimationChannel | undefined { - const binding = getBinding({ animations, propertyPath }); - const primaryChannelId = - binding != null ? getPrimaryChannelId({ binding }) : null; - return primaryChannelId ? animations?.channels[primaryChannelId] : undefined; -} - -export function upsertPathKeyframe({ - animations, - propertyPath, - time, - value, - interpolation, - keyframeId, - kind, - defaultInterpolation, - coerceValue, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - time: number; - value: AnimationValue; - interpolation?: AnimationInterpolation; - keyframeId?: string; - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; -}): ElementAnimations | undefined { - const coercedValue = coerceValue({ value }); - if (coercedValue === null) { - return animations; - } - - const nextAnimations = cloneAnimationsState({ animations }); - const existingBinding = getBinding({ - animations, - propertyPath, - }); - const binding = - existingBinding && existingBinding.kind === kind - ? cloneAnimationBinding({ binding: existingBinding }) - : createAnimationBinding({ path: propertyPath, kind }); - const primaryChannel = getChannel({ - animations, - propertyPath, - }); - const targetKey = getTargetKeyMetadata({ - channel: primaryChannel, - time, - keyframeId, - }); - const componentValues = decomposeAnimationValue({ - kind, - value: coercedValue, - }); - if (!componentValues) { - return animations; - } - - const explicitInterpolation = - interpolation != null - ? getInterpolationForBinding({ kind, interpolation }) - : undefined; - const validatedDefaultInterpolation = getInterpolationForBinding({ - kind, - interpolation: defaultInterpolation, - }); - nextAnimations.bindings[propertyPath] = binding; - for (const component of binding.components) { - const nextValue = componentValues[component.key]; - if (nextValue == null) { - continue; - } - - const currentChannel = getChannelById({ - animations, - channelId: component.channelId, - }); - const targetChannel = - currentChannel?.kind === getBindingChannelKind({ kind }) - ? currentChannel - : createEmptyChannelForBindingKind({ kind }); - nextAnimations.channels[component.channelId] = - targetChannel.kind === "discrete" - ? upsertDiscreteChannelKey({ - channel: targetChannel, - time: targetKey.time, - value: nextValue as string | boolean, - keyframeId: targetKey.id, - }) - : upsertScalarChannelKey({ - channel: targetChannel, - time: targetKey.time, - value: nextValue as number, - interpolation: explicitInterpolation, - defaultInterpolation: validatedDefaultInterpolation, - keyframeId: targetKey.id, - }); - } - - return toAnimation({ - animations: nextAnimations, - }); -} - -export function upsertElementKeyframe({ - animations, - propertyPath, - time, - value, - interpolation, - keyframeId, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - time: number; - value: AnimationValue; - interpolation?: AnimationInterpolation; - keyframeId?: string; -}): ElementAnimations | undefined { - const coercedValue = coerceAnimationValueForProperty({ - propertyPath, - value, - }); - if (coercedValue === null) { - return animations; - } - - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return upsertPathKeyframe({ - animations, - propertyPath, - time, - value: coercedValue, - interpolation, - keyframeId, - kind: propertyDefinition.kind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - coerceValue: ({ value: nextValue }) => - coerceAnimationValueForProperty({ - propertyPath, - value: nextValue, - }), - }); -} - -export function upsertKeyframe({ - channel, - time, - value, - interpolation, - keyframeId, -}: { - channel: AnimationChannel | undefined; - time: number; - value: AnimationValue; - interpolation?: AnimationInterpolation; - keyframeId?: string; -}): AnimationChannel | undefined { - if (!channel) { - return undefined; - } - - if (channel.kind === "discrete") { - if (typeof value !== "string" && typeof value !== "boolean") { - return channel; - } - - return upsertDiscreteChannelKey({ - channel, - time, - value, - keyframeId, - }); - } - - if (typeof value !== "number") { - return channel; - } - - return upsertScalarChannelKey({ - channel, - time, - value, - interpolation, - keyframeId, - }); -} - -export function removeKeyframe({ - channel, - keyframeId, -}: { - channel: AnimationChannel | undefined; - keyframeId: string; -}): AnimationChannel | undefined { - if (!channel) { - return undefined; - } - - const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId); - if (nextKeys.length === 0) { - return undefined; - } - - return normalizeChannel({ - channel: { - ...channel, - keys: nextKeys, - } as AnimationChannel, - }); -} - -export function retimeKeyframe({ - channel, - keyframeId, - time, -}: { - channel: AnimationChannel | undefined; - keyframeId: string; - time: number; -}): AnimationChannel | undefined { - if (!channel) { - return undefined; - } - - const keyframeByIdIndex = channel.keys.findIndex( - (keyframe) => keyframe.id === keyframeId, - ); - if (keyframeByIdIndex < 0) { - return channel; - } - - const nextKeys = [...channel.keys]; - nextKeys[keyframeByIdIndex] = { - ...nextKeys[keyframeByIdIndex], - time, - }; - - return normalizeChannel({ - channel: { - ...channel, - keys: nextKeys, - } as AnimationChannel, - }); -} - -export function setChannel({ - animations, - propertyPath, - channel, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - channel: AnimationChannel | undefined; -}): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - if (binding.components.length !== 1) { - throw new Error( - `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`, - ); - } - - const primaryComponent = getPrimaryComponent({ binding }); - if (!primaryComponent) { - return animations; - } - - return setBindingComponentChannel({ - animations, - propertyPath, - componentKey: primaryComponent.key, - channel, - }); -} - -export function setBindingComponentChannel({ - animations, - propertyPath, - componentKey, - channel, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - componentKey: string; - channel: AnimationChannel | undefined; -}): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - const component = getBindingComponent({ - binding, - componentKey, - }); - if (!component) { - return animations; - } - - const nextAnimations = cloneAnimationsState({ animations }); - if (!channel || !hasChannelKeys({ channel })) { - delete nextAnimations.channels[component.channelId]; - const hasRemainingKeys = binding.components.some((candidate) => - hasChannelKeys({ - channel: nextAnimations.channels[candidate.channelId], - }), - ); - if (!hasRemainingKeys) { - delete nextAnimations.bindings[propertyPath]; - } - return toAnimation({ - animations: nextAnimations, - }); - } - - nextAnimations.channels[component.channelId] = normalizeChannel({ - channel, - }); - return toAnimation({ - animations: nextAnimations, - }); -} - -export function updateScalarKeyframeCurve({ - animations, - propertyPath, - componentKey, - keyframeId, - patch, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - componentKey: string; - keyframeId: string; - patch: ScalarCurveKeyframePatch; -}): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - const component = getBindingComponent({ - binding, - componentKey, - }); - if (!component) { - return animations; - } - - const channel = getChannelById({ - animations, - channelId: component.channelId, - }); - if (channel?.kind !== "scalar") { - return animations; - } - - const keyframeIndex = channel.keys.findIndex((keyframe) => keyframe.id === keyframeId); - if (keyframeIndex < 0) { - return animations; - } - - const nextKeys = [...channel.keys]; - const currentKey = nextKeys[keyframeIndex]; - nextKeys[keyframeIndex] = { - ...currentKey, - leftHandle: - patch.leftHandle === undefined - ? currentKey.leftHandle - : patch.leftHandle ?? undefined, - rightHandle: - patch.rightHandle === undefined - ? currentKey.rightHandle - : patch.rightHandle ?? undefined, - segmentToNext: patch.segmentToNext ?? currentKey.segmentToNext, - tangentMode: patch.tangentMode ?? currentKey.tangentMode, - }; - - return setBindingComponentChannel({ - animations, - propertyPath, - componentKey, - channel: { - kind: "scalar", - keys: nextKeys, - extrapolation: channel.extrapolation, - }, - }); -} - -export function cloneAnimations({ - animations, - shouldRegenerateKeyframeIds = false, -}: { - animations: ElementAnimations | undefined; - shouldRegenerateKeyframeIds?: boolean; -}): ElementAnimations | undefined { - if (!animations) { - return undefined; - } - - const nextAnimations = cloneAnimationsState({ animations }); - nextAnimations.bindings = Object.fromEntries( - Object.entries(animations.bindings).map(([path, binding]) => [ - path, - binding ? cloneAnimationBinding({ binding }) : binding, - ]), - ); - nextAnimations.channels = {}; - - for (const binding of Object.values(nextAnimations.bindings)) { - if (!binding) { - continue; - } - - const primaryChannel = getChannelById({ - animations, - channelId: getPrimaryChannelId({ binding }) ?? "", - }); - const keyIdMap = new Map<string, string>(); - if (primaryChannel) { - for (const key of primaryChannel.keys) { - keyIdMap.set( - key.id, - shouldRegenerateKeyframeIds ? generateUUID() : key.id, - ); - } - } - - for (const component of binding.components) { - const currentChannel = getChannelById({ - animations, - channelId: component.channelId, - }); - if (!currentChannel) { - continue; - } - - nextAnimations.channels[component.channelId] = normalizeChannel({ - channel: { - ...currentChannel, - keys: currentChannel.keys.map((key) => ({ - ...key, - id: keyIdMap.get(key.id) ?? key.id, - })), - } as AnimationChannel, - }); - } - } - - return toAnimation({ - animations: nextAnimations, - }); -} - -export function clampAnimationsToDuration({ - animations, - duration, -}: { - animations: ElementAnimations | undefined; - duration: number; -}): ElementAnimations | undefined { - if (!animations || duration <= 0) { - return undefined; - } - - return splitAnimationsAtTime({ - animations, - splitTime: duration, - shouldIncludeSplitBoundary: true, - }).leftAnimations; -} - -function lerpPoint({ - left, - right, - progress, -}: { - left: { x: number; y: number }; - right: { x: number; y: number }; - progress: number; -}) { - return { - x: left.x + (right.x - left.x) * progress, - y: left.y + (right.y - left.y) * progress, - }; -} - -function splitDiscreteChannelAtTime({ - channel, - splitTime, - leftBoundaryId, - rightBoundaryId, - shouldIncludeSplitBoundary, -}: { - channel: DiscreteAnimationChannel | undefined; - splitTime: number; - leftBoundaryId: string; - rightBoundaryId: string; - shouldIncludeSplitBoundary: boolean; -}) { - if (!channel || channel.keys.length === 0) { - return { - leftChannel: undefined, - rightChannel: undefined, - }; - } - - const normalizedChannel = normalizeChannel({ channel }); - let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); - let rightKeys = normalizedChannel.keys - .filter((key) => key.time >= splitTime) - .map((key) => ({ ...key, time: key.time - splitTime })); - - if (shouldIncludeSplitBoundary) { - const hasBoundaryOnLeft = leftKeys.some((key) => - isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), - ); - const hasBoundaryOnRight = rightKeys.some((key) => - isNearlySameTime({ leftTime: key.time, rightTime: 0 }), - ); - const boundaryValue = getChannelValueAtTime({ - channel: normalizedChannel, - time: splitTime, - fallbackValue: normalizedChannel.keys[0].value, - }); - if (!hasBoundaryOnLeft) { - leftKeys = [ - ...leftKeys, - createDiscreteKey({ - id: leftBoundaryId, - time: splitTime, - value: boundaryValue as string | boolean, - }), - ]; - } - if (!hasBoundaryOnRight) { - rightKeys = [ - createDiscreteKey({ - id: rightBoundaryId, - time: 0, - value: boundaryValue as string | boolean, - }), - ...rightKeys, - ]; - } - } - - return { - leftChannel: leftKeys.length - ? normalizeChannel({ channel: { kind: "discrete", keys: leftKeys } }) - : undefined, - rightChannel: rightKeys.length - ? normalizeChannel({ channel: { kind: "discrete", keys: rightKeys } }) - : undefined, - }; -} - -function splitScalarChannelAtTime({ - channel, - splitTime, - leftBoundaryId, - rightBoundaryId, - shouldIncludeSplitBoundary, -}: { - channel: ScalarAnimationChannel | undefined; - splitTime: number; - leftBoundaryId: string; - rightBoundaryId: string; - shouldIncludeSplitBoundary: boolean; -}) { - if (!channel || channel.keys.length === 0) { - return { - leftChannel: undefined, - rightChannel: undefined, - }; - } - - const normalizedChannel = normalizeChannel({ channel }); - let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); - let rightKeys = normalizedChannel.keys - .filter((key) => key.time >= splitTime) - .map((key) => ({ ...key, time: key.time - splitTime })); - - const hasBoundaryOnLeft = leftKeys.some((key) => - isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), - ); - const hasBoundaryOnRight = rightKeys.some((key) => - isNearlySameTime({ leftTime: key.time, rightTime: 0 }), - ); - if (!shouldIncludeSplitBoundary || (hasBoundaryOnLeft && hasBoundaryOnRight)) { - return { - leftChannel: leftKeys.length - ? normalizeChannel({ - channel: { - kind: "scalar", - keys: leftKeys, - extrapolation: normalizedChannel.extrapolation, - }, - }) - : undefined, - rightChannel: rightKeys.length - ? normalizeChannel({ - channel: { - kind: "scalar", - keys: rightKeys, - extrapolation: normalizedChannel.extrapolation, - }, - }) - : undefined, - }; - } - - for (let keyIndex = 0; keyIndex < normalizedChannel.keys.length - 1; keyIndex++) { - const leftKey = normalizedChannel.keys[keyIndex]; - const rightKey = normalizedChannel.keys[keyIndex + 1]; - if ( - !( - splitTime > leftKey.time && - splitTime < rightKey.time - ) - ) { - continue; - } - - const boundaryValue = getChannelValueAtTime({ - channel: normalizedChannel, - time: splitTime, - fallbackValue: leftKey.value, - }) as number; - - if (leftKey.segmentToNext === "bezier") { - const rightHandle = - leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); - const leftHandle = - rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); - const progress = solveBezierProgressForTime({ - time: splitTime, - leftKey, - rightKey, - }); - const p0 = { x: leftKey.time, y: leftKey.value }; - const p1 = { - x: leftKey.time + rightHandle.dt, - y: leftKey.value + rightHandle.dv, - }; - const p2 = { - x: rightKey.time + leftHandle.dt, - y: rightKey.value + leftHandle.dv, - }; - const p3 = { x: rightKey.time, y: rightKey.value }; - const q0 = lerpPoint({ left: p0, right: p1, progress }); - const q1 = lerpPoint({ left: p1, right: p2, progress }); - const q2 = lerpPoint({ left: p2, right: p3, progress }); - const r0 = lerpPoint({ left: q0, right: q1, progress }); - const r1 = lerpPoint({ left: q1, right: q2, progress }); - const splitPoint = lerpPoint({ left: r0, right: r1, progress }); - leftKeys = [ - ...normalizedChannel.keys.filter((key) => key.time < splitTime), - { - ...leftKey, - rightHandle: { - dt: q0.x - p0.x, - dv: q0.y - p0.y, - }, - }, - { - id: leftBoundaryId, - time: splitTime, - value: boundaryValue, - leftHandle: { - dt: r0.x - splitPoint.x, - dv: r0.y - splitPoint.y, - }, - segmentToNext: leftKey.segmentToNext, - tangentMode: leftKey.tangentMode, - }, - ]; - rightKeys = [ - { - id: rightBoundaryId, - time: 0, - value: boundaryValue, - rightHandle: { - dt: r1.x - splitPoint.x, - dv: r1.y - splitPoint.y, - }, - segmentToNext: "bezier", - tangentMode: leftKey.tangentMode, - }, - { - ...rightKey, - time: rightKey.time - splitTime, - leftHandle: { - dt: q2.x - p3.x, - dv: q2.y - p3.y, - }, - }, - ...normalizedChannel.keys - .filter((key) => key.time > rightKey.time) - .map((key) => ({ - ...key, - time: key.time - splitTime, - })), - ]; - } else { - leftKeys = [ - ...leftKeys, - createScalarKey({ - id: leftBoundaryId, - time: splitTime, - value: boundaryValue, - interpolation: "linear", - }), - ]; - rightKeys = [ - createScalarKey({ - id: rightBoundaryId, - time: 0, - value: boundaryValue, - interpolation: getScalarSegmentInterpolation({ - segment: leftKey.segmentToNext, - }), - }), - ...rightKeys, - ]; - } - - return { - leftChannel: normalizeChannel({ - channel: { - kind: "scalar", - keys: leftKeys, - extrapolation: normalizedChannel.extrapolation, - }, - }), - rightChannel: normalizeChannel({ - channel: { - kind: "scalar", - keys: rightKeys, - extrapolation: normalizedChannel.extrapolation, - }, - }), - }; - } - - return { - leftChannel: leftKeys.length - ? normalizeChannel({ - channel: { - kind: "scalar", - keys: leftKeys, - extrapolation: normalizedChannel.extrapolation, - }, - }) - : undefined, - rightChannel: rightKeys.length - ? normalizeChannel({ - channel: { - kind: "scalar", - keys: rightKeys, - extrapolation: normalizedChannel.extrapolation, - }, - }) - : undefined, - }; -} - -export function splitAnimationsAtTime({ - animations, - splitTime, - shouldIncludeSplitBoundary = true, -}: { - animations: ElementAnimations | undefined; - splitTime: number; - shouldIncludeSplitBoundary?: boolean; -}): { - leftAnimations: ElementAnimations | undefined; - rightAnimations: ElementAnimations | undefined; -} { - if (!animations) { - return { leftAnimations: undefined, rightAnimations: undefined }; - } - - const leftAnimations = cloneAnimationsState({ animations: undefined }); - const rightAnimations = cloneAnimationsState({ animations: undefined }); - - for (const [propertyPath, binding] of Object.entries(animations.bindings)) { - if (!binding) { - continue; - } - - const leftBinding = cloneAnimationBinding({ binding }); - const rightBinding = cloneAnimationBinding({ binding }); - const leftBoundaryId = generateUUID(); - const rightBoundaryId = generateUUID(); - let hasLeftKeys = false; - let hasRightKeys = false; - - for (const component of binding.components) { - const channel = getChannelById({ - animations, - channelId: component.channelId, - }); - const splitResult = - channel?.kind === "discrete" - ? splitDiscreteChannelAtTime({ - channel, - splitTime, - leftBoundaryId, - rightBoundaryId, - shouldIncludeSplitBoundary, - }) - : splitScalarChannelAtTime({ - channel: channel as ScalarAnimationChannel | undefined, - splitTime, - leftBoundaryId, - rightBoundaryId, - shouldIncludeSplitBoundary, - }); - if (splitResult.leftChannel) { - leftAnimations.channels[component.channelId] = splitResult.leftChannel; - hasLeftKeys = true; - } - if (splitResult.rightChannel) { - rightAnimations.channels[component.channelId] = splitResult.rightChannel; - hasRightKeys = true; - } - } - - if (hasLeftKeys) { - leftAnimations.bindings[propertyPath] = leftBinding; - } - if (hasRightKeys) { - rightAnimations.bindings[propertyPath] = rightBinding; - } - } - - return { - leftAnimations: toAnimation({ animations: leftAnimations }), - rightAnimations: toAnimation({ animations: rightAnimations }), - }; -} - -export function removeElementKeyframe({ - animations, - propertyPath, - keyframeId, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - keyframeId: string; -}): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - const nextAnimations = cloneAnimationsState({ animations }); - for (const component of binding.components) { - nextAnimations.channels[component.channelId] = removeKeyframe({ - channel: nextAnimations.channels[component.channelId], - keyframeId, - }); - } - const hasRemainingKeys = binding.components.some((component) => - hasChannelKeys({ - channel: nextAnimations.channels[component.channelId], - }), - ); - if (!hasRemainingKeys) { - delete nextAnimations.bindings[propertyPath]; - for (const component of binding.components) { - delete nextAnimations.channels[component.channelId]; - } - } - return toAnimation({ - animations: nextAnimations, - }); -} - -export function retimeElementKeyframe({ - animations, - propertyPath, - keyframeId, - time, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - keyframeId: string; - time: number; -}): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - const nextAnimations = cloneAnimationsState({ animations }); - for (const component of binding.components) { - nextAnimations.channels[component.channelId] = retimeKeyframe({ - channel: nextAnimations.channels[component.channelId], - keyframeId, - time, - }); - } - return toAnimation({ - animations: nextAnimations, - }); -} diff --git a/apps/web/src/lib/animation/property-groups.ts b/apps/web/src/lib/animation/property-groups.ts deleted file mode 100644 index 1e475dac..00000000 --- a/apps/web/src/lib/animation/property-groups.ts +++ /dev/null @@ -1,39 +0,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 deleted file mode 100644 index 56340fd8..00000000 --- a/apps/web/src/lib/animation/property-registry.ts +++ /dev/null @@ -1,390 +0,0 @@ -import type { - AnimationBindingKind, - AnimationInterpolation, - AnimationPropertyPath, - AnimationValue, -} from "@/lib/animation/types"; -import { parseColorToLinearRgba } from "./binding-values"; -import type { TimelineElement } from "@/lib/timeline"; -import { MIN_TRANSFORM_SCALE } from "@/lib/animation/transform"; -import { - CORNER_RADIUS_MAX, - CORNER_RADIUS_MIN, -} from "@/lib/text/background"; -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 interface AnimationPropertyDefinition { - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - numericRanges?: Partial<Record<string, NumericSpec>>; - supportsElement: ({ element }: { element: TimelineElement }) => boolean; - getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; - setValue: ({ - element, - value, - }: { - element: TimelineElement; - value: AnimationValue; - }) => TimelineElement; -} - -function applyNumericSpec({ - value, - numericRange, -}: { - value: number; - numericRange: NumericSpec | undefined; -}): number { - if (!numericRange) { - return value; - } - - const steppedValue = - numericRange.step != null - ? snapToStep({ value, step: numericRange.step }) - : value; - const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; - const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); -} - -function coerceNumberValue({ - value, - numericRange, -}: { - value: AnimationValue; - numericRange?: NumericSpec; -}): number | null { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - return applyNumericSpec({ value, numericRange }); -} - -function coerceColorValue({ - value, -}: { - value: AnimationValue; -}): string | null { - return typeof value === "string" && parseColorToLinearRgba({ color: value }) - ? value - : null; -} - -function createNumberPropertyDefinition({ - numericRange, - supportsElement, - getValue, - setValue, -}: { - numericRange?: NumericSpec; - supportsElement: AnimationPropertyDefinition["supportsElement"]; - getValue: AnimationPropertyDefinition["getValue"]; - setValue: AnimationPropertyDefinition["setValue"]; -}): AnimationPropertyDefinition { - return { - kind: "number", - defaultInterpolation: "linear", - numericRanges: numericRange ? { value: numericRange } : undefined, - supportsElement, - getValue, - coerceValue: ({ value }) => - coerceNumberValue({ - value, - numericRange, - }), - setValue, - }; -} - -const ANIMATION_PROPERTY_REGISTRY: Record< - AnimationPropertyPath, - AnimationPropertyDefinition -> = { - "transform.positionX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.x : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, x: value as number }, - }, - } - : element, - }), - "transform.positionY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.y : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, y: value as number }, - }, - } - : element, - }), - "transform.scaleX": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleX : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleX: value as number }, - } - : element, - }), - "transform.scaleY": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleY : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleY: value as number }, - } - : element, - }), - "transform.rotate": createNumberPropertyDefinition({ - numericRange: { min: -360, max: 360, step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.rotate : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, rotate: value as number }, - } - : element, - }), - opacity: createNumberPropertyDefinition({ - numericRange: { min: 0, max: 1, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.opacity : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { ...element, opacity: value as number } - : element, - }), - volume: createNumberPropertyDefinition({ - numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, - supportsElement: ({ element }) => canElementHaveAudio(element), - getValue: ({ element }) => - canElementHaveAudio(element) ? element.volume ?? 0 : null, - setValue: ({ element, value }) => - canElementHaveAudio(element) - ? { ...element, volume: value as number } - : element, - }), - color: { - kind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => (element.type === "text" ? element.color : null), - coerceValue: ({ value }) => coerceColorValue({ value }), - setValue: ({ element, value }) => - element.type === "text" - ? { ...element, color: value as string } - : element, - }, - "background.color": { - kind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" ? element.background.color : null, - coerceValue: ({ value }) => coerceColorValue({ value }), - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, color: value as string }, - } - : element, - }, - "background.paddingX": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingX: value as number }, - } - : element, - }), - "background.paddingY": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingY: value as number }, - } - : element, - }), - "background.offsetX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetX: value as number }, - } - : element, - }), - "background.offsetY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetY: value as number }, - } - : element, - }), - "background.cornerRadius": createNumberPropertyDefinition({ - numericRange: { - min: CORNER_RADIUS_MIN, - max: CORNER_RADIUS_MAX, - step: 1, - }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, cornerRadius: value as number }, - } - : element, - }), -}; - -export function isAnimationPropertyPath( - propertyPath: string, -): propertyPath is AnimationPropertyPath { - return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); -} - -export function getAnimationPropertyDefinition({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationPropertyDefinition { - return ANIMATION_PROPERTY_REGISTRY[propertyPath]; -} - -export function supportsAnimationProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): boolean { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.supportsElement({ element }); -} - -export function getElementBaseValueForProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): AnimationValue | null { - const definition = getAnimationPropertyDefinition({ propertyPath }); - if (!definition.supportsElement({ element })) { - return null; - } - return definition.getValue({ element }); -} - -export function withElementBaseValueForProperty({ - element, - propertyPath, - value, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): TimelineElement { - const definition = getAnimationPropertyDefinition({ propertyPath }); - const coercedValue = definition.coerceValue({ value }); - if (coercedValue === null || !definition.supportsElement({ element })) { - return element; - } - return definition.setValue({ element, value: coercedValue }); -} - -export function getDefaultInterpolationForProperty({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationInterpolation { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.defaultInterpolation; -} - -export function coerceAnimationValueForProperty({ - propertyPath, - value, -}: { - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): AnimationValue | null { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.coerceValue({ value }); -} diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts deleted file mode 100644 index 42c1c73c..00000000 --- a/apps/web/src/lib/animation/resolve.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { - AnimationColorPropertyPath, - AnimationNumericPropertyPath, - AnimationPath, - AnimationPropertyPath, - AnimationValueForPath, - ElementAnimations, -} from "@/lib/animation/types"; -import type { Transform } from "@/lib/rendering"; -import { - type AnimationComponentValue, - composeAnimationValue, - decomposeAnimationValue, -} from "./binding-values"; -import { - getChannelValueAtTime, -} from "./interpolation"; - -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: { - x: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.positionX", - localTime: safeLocalTime, - fallbackValue: baseTransform.position.x, - }), - y: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.positionY", - localTime: safeLocalTime, - fallbackValue: baseTransform.position.y, - }), - }, - scaleX: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.scaleX", - localTime: safeLocalTime, - fallbackValue: baseTransform.scaleX, - }), - scaleY: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.scaleY", - localTime: safeLocalTime, - fallbackValue: baseTransform.scaleY, - }), - rotate: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.rotate", - localTime: safeLocalTime, - fallbackValue: baseTransform.rotate, - }), - }; -} - -export function resolveOpacityAtTime({ - baseOpacity, - animations, - localTime, -}: { - baseOpacity: number; - animations: ElementAnimations | undefined; - localTime: number; -}): number { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath: "opacity", - localTime: Math.max(0, localTime), - fallbackValue: baseOpacity, - }); -} - -export function resolveNumberAtTime({ - baseValue, - animations, - propertyPath, - localTime, -}: { - baseValue: number; - animations: ElementAnimations | undefined; - propertyPath: AnimationNumericPropertyPath; - localTime: number; -}): number { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath, - localTime: Math.max(0, localTime), - fallbackValue: baseValue, - }); -} - -export function resolveColorAtTime({ - baseColor, - animations, - propertyPath, - localTime, -}: { - baseColor: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationColorPropertyPath; - localTime: number; -}): string { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath, - localTime: Math.max(0, localTime), - fallbackValue: baseColor, - }); -} - -export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({ - animations, - propertyPath, - localTime, - fallbackValue, -}: { - animations: ElementAnimations | undefined; - propertyPath: TPath; - localTime: number; - fallbackValue: AnimationValueForPath<TPath>; -}): AnimationValueForPath<TPath> { - const binding = animations?.bindings[propertyPath]; - if (!binding) { - return fallbackValue; - } - - const fallbackComponents = decomposeAnimationValue({ - kind: binding.kind, - value: fallbackValue, - }); - if (!fallbackComponents) { - return fallbackValue; - } - - const componentValues = Object.fromEntries( - binding.components.map((component) => { - const channel = animations?.channels[component.channelId]; - return [ - component.key, - getChannelValueAtTime({ - channel, - time: localTime, - fallbackValue: - fallbackComponents[component.key] ?? - (channel?.kind === "discrete" ? false : 0), - }), - ]; - }), - ) as Record<string, AnimationComponentValue | undefined>; - return (composeAnimationValue({ - binding, - componentValues, - }) ?? fallbackValue) as AnimationValueForPath<TPath>; -} diff --git a/apps/web/src/lib/animation/target-resolver.ts b/apps/web/src/lib/animation/target-resolver.ts deleted file mode 100644 index 64bb8709..00000000 --- a/apps/web/src/lib/animation/target-resolver.ts +++ /dev/null @@ -1,285 +0,0 @@ -import type { - AnimationBindingKind, - AnimationInterpolation, - AnimationPath, - AnimationValue, -} 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"; -import { parseColorToLinearRgba } from "./binding-values"; - -export interface AnimationPathDescriptor { - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - numericRanges?: Partial<Record<string, NumericSpec>>; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; - getBaseValue: () => AnimationValue | null; - setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement; -} - -export function getParamValueKind({ - param, -}: { - param: ParamDefinition; -}): AnimationBindingKind { - if (param.type === "number") { - return "number"; - } - - if (param.type === "color") { - return "color"; - } - - return "discrete"; -} - -export function getParamDefaultInterpolation({ - param, -}: { - param: ParamDefinition; -}): AnimationInterpolation { - return param.type === "number" || param.type === "color" ? "linear" : "hold"; -} - -function getParamNumericRange({ - param, -}: { - param: ParamDefinition; -}): Partial<Record<string, NumericSpec>> | undefined { - if (param.type !== "number") { - return undefined; - } - - return { - value: { - min: param.min, - max: param.max, - step: param.step, - }, - }; -} - -export function coerceAnimationValueForParam({ - param, - value, -}: { - param: ParamDefinition; - value: AnimationValue; -}): number | string | boolean | null { - if (param.type === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - const steppedValue = snapToStep({ value, step: param.step }); - const minValue = param.min; - const maxValue = param.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); - } - - if (param.type === "color") { - return typeof value === "string" ? value : null; - } - - if (param.type === "boolean") { - return typeof value === "boolean" ? value : null; - } - - if (typeof value !== "string") { - return null; - } - - return param.options.some((option) => option.value === value) ? value : null; -} - -function buildGraphicParamDescriptor({ - element, - paramKey, -}: { - element: TimelineElement; - paramKey: string; -}): AnimationPathDescriptor | null { - if (element.type !== "graphic") { - return null; - } - - const definition = getGraphicDefinition({ - definitionId: element.definitionId, - }); - const param = definition.params.find((candidate) => candidate.key === paramKey); - if (!param) { - return null; - } - - return { - kind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRanges: getParamNumericRange({ param }), - coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }), - getBaseValue: () => element.params[param.key] ?? param.default, - setBaseValue: ({ value }) => { - const coercedValue = coerceAnimationValueForParam({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - params: { - ...element.params, - [param.key]: coercedValue, - }, - }; - }, - }; -} - -function buildEffectParamDescriptor({ - element, - effectId, - paramKey, -}: { - element: TimelineElement; - effectId: string; - paramKey: string; -}): AnimationPathDescriptor | null { - if (!isVisualElement(element)) { - return null; - } - - const effect = element.effects?.find((candidate) => candidate.id === effectId); - if (!effect) { - return null; - } - - registerDefaultEffects(); - const definition = effectsRegistry.get(effect.type); - const param = definition.params.find((candidate) => candidate.key === paramKey); - if (!param) { - return null; - } - - return { - kind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRanges: getParamNumericRange({ param }), - coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }), - getBaseValue: () => effect.params[param.key] ?? param.default, - setBaseValue: ({ value }) => { - const coercedValue = coerceAnimationValueForParam({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - effects: - element.effects?.map((candidate) => - candidate.id !== effectId - ? candidate - : { - ...candidate, - params: { - ...candidate.params, - [param.key]: coercedValue, - }, - }, - ) ?? element.effects, - }; - }, - }; -} - -export function isAnimationPath( - propertyPath: string, -): propertyPath is AnimationPath { - return ( - isAnimationPropertyPath(propertyPath) || - isGraphicParamPath(propertyPath) || - isEffectParamPath(propertyPath) - ); -} - -export function resolveAnimationTarget({ - element, - path, -}: { - element: TimelineElement; - path: AnimationPath; -}): AnimationPathDescriptor | null { - if (isAnimationPropertyPath(path)) { - const propertyDefinition = getAnimationPropertyDefinition({ - propertyPath: path, - }); - if (!propertyDefinition.supportsElement({ element })) { - return null; - } - - return { - kind: propertyDefinition.kind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - numericRanges: propertyDefinition.numericRanges, - coerceValue: ({ value }) => - coerceAnimationValueForProperty({ - propertyPath: path, - value, - }), - getBaseValue: () => - getElementBaseValueForProperty({ - element, - propertyPath: path, - }), - setBaseValue: ({ value }) => { - const coercedValue = propertyDefinition.coerceValue({ value }); - if (coercedValue === null) { - return element; - } - - return withElementBaseValueForProperty({ - element, - propertyPath: path, - value: coercedValue, - }); - }, - }; - } - - const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); - if (graphicParamTarget) { - return buildGraphicParamDescriptor({ - element, - paramKey: graphicParamTarget.paramKey, - }); - } - - const effectParamTarget = parseEffectParamPath({ propertyPath: path }); - if (effectParamTarget) { - return buildEffectParamDescriptor({ - element, - effectId: effectParamTarget.effectId, - paramKey: effectParamTarget.paramKey, - }); - } - - return null; -} diff --git a/apps/web/src/lib/animation/transform.ts b/apps/web/src/lib/animation/transform.ts deleted file mode 100644 index ab932ea2..00000000 --- a/apps/web/src/lib/animation/transform.ts +++ /dev/null @@ -1 +0,0 @@ -export const MIN_TRANSFORM_SCALE = 0.01; diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts deleted file mode 100644 index 115b4dbf..00000000 --- a/apps/web/src/lib/animation/types.ts +++ /dev/null @@ -1,222 +0,0 @@ -import type { ParamValues } from "@/lib/params"; - -export const ANIMATION_PROPERTY_PATHS = [ - "transform.positionX", - "transform.positionY", - "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 DiscreteValue = boolean | string; -export type AnimationValue = number | string | boolean | VectorValue; -export interface AnimationPropertyValueMap { - "transform.positionX": number; - "transform.positionY": number; - "transform.scaleX": number; - "transform.scaleY": number; - "transform.rotate": number; - opacity: number; - volume: number; - color: string; - "background.color": string; - "background.paddingX": number; - "background.paddingY": number; - "background.offsetX": number; - "background.offsetY": number; - "background.cornerRadius": number; -} -export type DynamicAnimationPathValue = ParamValues[string]; -export type AnimationValueForPath<TPath extends AnimationPath> = - TPath extends AnimationPropertyPath - ? AnimationPropertyValueMap[TPath] - : TPath extends GraphicParamPath | EffectParamPath - ? DynamicAnimationPathValue - : never; -export type AnimationNumericPropertyPath = { - [K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never; -}[AnimationPropertyPath]; -export type AnimationColorPropertyPath = { - [K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never; -}[AnimationPropertyPath]; - -export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier"; -export type DiscreteKeyframeInterpolation = "hold"; -export type AnimationInterpolation = - | ContinuousKeyframeInterpolation - | DiscreteKeyframeInterpolation; - -export type PrimitiveAnimationChannelKind = "scalar" | "discrete"; -export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete"; -export type ScalarSegmentType = "step" | "linear" | "bezier"; -export type TangentMode = "auto" | "aligned" | "broken" | "flat"; -export type ChannelExtrapolationMode = "hold" | "linear"; - -export interface CurveHandle { - dt: number; - dv: number; -} - -interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> { - id: string; - time: number; // relative to element start time - value: TValue; -} - -export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> { - leftHandle?: CurveHandle; - rightHandle?: CurveHandle; - segmentToNext: ScalarSegmentType; - tangentMode: TangentMode; -} - -export interface DiscreteAnimationKey - extends BaseAnimationKeyframe<DiscreteValue> {} - -export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey; - -export interface ScalarAnimationChannel { - kind: "scalar"; - keys: ScalarAnimationKey[]; - extrapolation?: { - before: ChannelExtrapolationMode; - after: ChannelExtrapolationMode; - }; -} - -export interface DiscreteAnimationChannel { - kind: "discrete"; - keys: DiscreteAnimationKey[]; -} - -export type AnimationChannel = - | ScalarAnimationChannel - | DiscreteAnimationChannel; - -export type ElementAnimationChannelMap = Record< - string, - AnimationChannel | undefined ->; - -export interface AnimationBindingComponent<TKey extends string = string> { - key: TKey; - channelId: string; -} - -interface BaseAnimationBinding< - TKind extends AnimationBindingKind, - TComponentKey extends string, -> { - path: AnimationPath; - kind: TKind; - components: AnimationBindingComponent<TComponentKey>[]; -} - -export interface NumberAnimationBinding - extends BaseAnimationBinding<"number", "value"> {} - -export interface Vector2AnimationBinding - extends BaseAnimationBinding<"vector2", "x" | "y"> {} - -export interface ColorAnimationBinding - extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> { - colorSpace: "srgb-linear"; -} - -export interface DiscreteAnimationBinding - extends BaseAnimationBinding<"discrete", "value"> {} - -export type AnimationBindingInstance = - | NumberAnimationBinding - | Vector2AnimationBinding - | ColorAnimationBinding - | DiscreteAnimationBinding; - -export interface AnimationBindingByKind { - number: NumberAnimationBinding; - vector2: Vector2AnimationBinding; - color: ColorAnimationBinding; - discrete: DiscreteAnimationBinding; -} - -export type AnimationBindingOfKind<TKind extends AnimationBindingKind> = - AnimationBindingByKind[TKind]; - -export type ElementAnimationBindingMap = Record< - string, - AnimationBindingInstance | undefined ->; - -export interface ElementAnimations { - bindings: ElementAnimationBindingMap; - channels: ElementAnimationChannelMap; -} - -export type NormalizedCubicBezier = [number, number, number, number]; - -export interface ScalarGraphChannelTarget { - propertyPath: AnimationPath; - componentKey: string; - channelId: string; -} - -export interface ScalarGraphChannel extends ScalarGraphChannelTarget { - channel: ScalarAnimationChannel; -} - -export interface ScalarGraphKeyframeRef extends ScalarGraphChannelTarget { - keyframeId: string; -} - -export interface ScalarGraphKeyframeContext extends ScalarGraphChannel { - keyframe: ScalarAnimationKey; - keyframeIndex: number; - previousKey: ScalarAnimationKey | null; - nextKey: ScalarAnimationKey | null; -} - -export interface ScalarCurveKeyframePatch { - leftHandle?: CurveHandle | null; - rightHandle?: CurveHandle | null; - segmentToNext?: ScalarSegmentType; - tangentMode?: TangentMode; -} - -export interface ElementKeyframe { - propertyPath: AnimationPath; - id: string; - time: number; - value: AnimationValue; - interpolation: AnimationInterpolation; -} - -export interface SelectedKeyframeRef { - trackId: string; - elementId: string; - propertyPath: AnimationPath; - keyframeId: string; -} |
